Ранжирование (learning to rank)
Урок разбирает этап ранжирования: как из множества кандидатов составить идеальный порядок, обучая модель ранжирования на богатых признаках.
Learning to rank (LTR) — это обучение модели не предсказывать абсолютную оценку, а правильно упорядочивать кандидатов по релевантности для конкретного пользователя.
Зачем отдельный этап ранжирования
Генерация кандидатов (CF, two-tower, популярность) даёт сотни-тысячи потенциально релевантных товаров — но быстро и грубо. Показать-то нужно десяток, и в правильном порядке. Ранжирование — это второй, точный этап: по каждому кандидату собирается много признаков, и обучаемая модель расставляет их в финальный порядок. Здесь точность важнее скорости, потому что кандидатов уже немного.
Признаки ранжирования
Сила LTR — в богатстве признаков. На вход модели подают всё, что есть:
- Сигналы релевантности: скор из CF, близость эмбеддингов, контентное сходство.
- Признаки товара: популярность, свежесть, цена, рейтинг, наличие.
- Признаки пользователя: история, активность, сегмент.
- Признаки пары и контекста: совпадение категории с историей, время суток, устройство.
Три семейства подходов
| Подход | Что предсказывает |
| Pointwise | Скор каждого товара по отдельности (как регрессия/классификация) |
| Pairwise | Для пары товаров — какой выше (например, RankNet, LambdaRank) |
| Listwise | Качество всего списка целиком (оптимизирует метрику вроде NDCG) |
На практике золотой стандарт — градиентный бустинг для ранжирования (LambdaMART, реализован в XGBoost, LightGBM, CatBoost). Он мощный, работает с разнородными признаками и обучается на парных предпочтениях.
Простой линейный ранжировщик
Покажем суть pointwise-ранжирования на stdlib: взвешенная сумма признаков задаёт скор, по которому сортируем кандидатов.
# признаки кандидата: [cf_score, популярность, свежесть]
candidates = {
"A": [0.9, 0.2, 0.1],
"B": [0.4, 0.9, 0.8],
"C": [0.7, 0.5, 0.9],
"D": [0.6, 0.3, 0.2],
}
weights = [1.0, 0.5, 0.7] # обучаются на данных; здесь заданы вручную
def score(feats):
return sum(w * f for w, f in zip(weights, feats))
ranked = sorted(candidates, key=lambda x: -score(candidates[x]))
print("Финальный порядок:")
for item in ranked:
print(f" {item}: {round(score(candidates[item]), 3)}")Вывод:
Финальный порядок: C: 1.58 B: 1.41 A: 1.07 D: 0.89
Список уже отсортирован по скору: C (1.58) выше B (1.41), B выше A (1.07), A выше D (0.89). Скор — это просто взвешенная сумма признаков; меняя веса, мы меняем порядок.
Как работает под капотом
Настоящий LTR обучает веса (или деревья бустинга) так, чтобы порядок предсказаний совпадал с «правильным» порядком из данных — например, кликнутый товар должен оказаться выше некликнутого. Listwise-методы прямо оптимизируют ранжирующие метрики (NDCG@k), что обычно даёт лучший результат, чем pointwise. Признаки нормируют, бустинг подбирает нелинейные комбинации автоматически — поэтому ручная установка весов из примера на проде заменяется обучением.
Частые ошибки
- Путать генерацию кандидатов и ранжирование. Первое — широкий грубый отбор, второе — точная сортировка немногих; смешивать нельзя.
- Оптимизировать не ту цель. Если важен порядок в топе, оптимизируйте ранжирующую метрику (NDCG), а не общую регрессию.
- Утечка целевого признака. Случайно подать в признаки информацию о будущем клике — классическая ошибка, дающая обманчиво идеальный офлайн.
Итоги
- Ранжирование — точный второй этап после грубой генерации кандидатов.
- Сила LTR — в богатых признаках товара, пользователя, пары и контекста.
- Подходы: pointwise, pairwise, listwise; на практике царит бустинг (LambdaMART).
- Listwise-методы прямо оптимизируют ранжирующие метрики вроде NDCG.