Дополнение · X-алгоритм · страница 6 из 11
Скоринг: из вероятностей действий в одно число
Самая обсуждаемая часть системы и самая неправильно понимаемая. Здесь разбираем, что именно предсказывает модель, какими весами это сводится в один скор, что происходит со скором дальше — и почему из отношения весов нельзя делать выводы, которые из него обычно делают.
Коротко
- Модель предсказывает вероятность каждого действия отдельно: лайк, ответ, репост, клик, досмотр, подписка, жалоба, блокировка. Не «релевантность».
- Скор — это \(\sum_i w_i \cdot P(\text{действие}_i)\). Веса лежат в коде обычными числами; положительные действия с плюсом, негативные с минусом.
- Отношение весов жалобы и лайка равно 468, и из этого не следует, что жалоба перечёркивает 468 лайков: вес умножается на вероятность, а вероятность жалобы на несколько порядков меньше.
- Поверх суммы идут три поправки: затухание по повторному автору, дисконт за то, что вы не подписаны, и подъём одного поста малоизвестного автора.
- Последним работает отдельный сервис переранжирования, отбирающий посты детерминантным точечным процессом по эмбеддингам — тот самый DPP.
1. Что предсказывает модель
Головы модели сгруппированы по смыслу. Вот полный список из README, с пояснением, что каждое означает.
| Группа | Действия | Что это ловит |
|---|---|---|
| Вовлечение | лайк, ответ, репост, цитата, «поделиться», отправка в личку, копирование ссылки | Явные действия, требующие усилия. Чем больше усилия, тем сильнее сигнал |
| Клики | по посту, по профилю, по ссылке, разворот фото, открытие видео, клик по цитируемому | Интерес, не дошедший до явного действия |
| Внимание | качественный просмотр видео, задержка, время задержки, время после клика, активные секунды | Сколько внимания пост реально получил |
| Автор | подписка на автора | Самый сильный положительный сигнал: пост оказался настолько хорош, что зритель захотел ещё |
| Негатив | «не интересно», скрыть автора, заблокировать, пожаловаться, не задержаться | Явное и неявное отторжение |
Обратите внимание на последнюю строку: «не задержался» — это отдельная предсказываемая величина с отрицательным весом. Формально это не действие пользователя, а его отсутствие. Такой ход мы обсуждали в главе «Проблемы implicit-данных», когда говорили про неявный фидбек: пролистывание мимо — тоже информация, и не использовать её расточительно.
Соблазн понятен: обучить модель предсказывать сразу «полезность поста» и не мучиться с весами. На практике так не делают по двум причинам, и обе видны в этом коде.
Первая — продуктовая политика меняется чаще, чем модель. Решение «ответы ценнее лайков» — это решение о том, какую ленту мы хотим, а не о том, что модель может предсказать. Держать его в весах означает менять число в конфиге и раскатывать экспериментом. Держать его в лоссе означает переобучение на каждое такое решение.
Вторая — редкие сигналы иначе не выживают. Жалоба случается на несколько порядков реже лайка. В едином лоссе её вклад в градиент исчезающе мал, и модель просто не научится её предсказывать. Отдельная голова учится этому как самостоятельной задаче, а насколько сильно это влияет на выдачу — решает вес.
Ровно та же логика, что в мультизадачном обучении, только доведённая до конца: здесь все целевые величины разведены по головам, а сведение вынесено за пределы модели.
2. Веса: реальные числа
Веса объявлены макросом param! — имя, тип, ключ в системе конфигурации, значение по умолчанию. Дефолты в репозитории синхронизируются с продовыми скриптом, так что это не выдуманные числа.
param!(FavoriteWeight, f64, "rust_home_mixer_favorite_weight", 0.5);
param!(ReplyWeight, f64, "rust_home_mixer_reply_weight", 5.0);
param!(ShareViaCopyLinkWeight, f64, "rust_home_mixer_share_via_copy_link_weight", 20.0);
param!(ReportWeight, f64, "rust_home_mixer_report_weight", -234.0);
Фрагмент из param.rs · код X, Apache 2.0, коммит 28e414f
Фрагменты home-mixer/params/param.rs.
| Действие | Вес | Как читать |
|---|---|---|
| Копирование ссылки | +20.0 | Самый дорогой положительный сигнал. Человек унёс пост за пределы платформы — значит, он ценен настолько, что им делятся вручную |
| Ответ | +5.0 | Плюс ещё +15.0, если зритель и автор подписаны друг на друга — разговор между знакомыми ценится отдельно |
| Цитата | +5.0 | Репост со своим текстом: усилие больше, чем у простого репоста |
| Отправка в личку | +5.0 | Приватное распространение — сильный сигнал, который не виден в публичных счётчиках |
| Подписка на автора | +4.0 | Пост сработал настолько, что зритель захотел ещё |
| «Поделиться» | +2.0 | |
| Репост | +1.0 | Дешёвое действие, поэтому и вес умеренный |
| Лайк | +0.5 | Самое частое и самое дешёвое действие — отсюда небольшой вес |
| Клик по посту | +0.4 | |
| Открытие ссылки | +0.2 | |
| Разворот фото · открытие видео · качественный просмотр | +0.05 | Слабые сигналы внимания |
| Пост не изучен | +0.02 | Маленькая надбавка постам, о которых мало данных, — про неё ниже |
| Клик по профилю · задержка | 0.0 | Голова обучается, но в скор сейчас не входит. Ноль — тоже настройка |
| Не задержался | −0.02 | Слабый, но очень частый сигнал |
| Заблокировать автора | −31.2 | |
| «Не интересно» | −43.2 | |
| Скрыть автора | −58.8 | Дороже блокировки: блокируют часто в конфликте, а скрывают именно из-за контента |
| Пожаловаться | −234.0 | Самый дорогой сигнал в системе |
Сложив веса по группам, получаем числа, которые прямо используются в коде:
- сумма положительных весов — 43.32;
- сумма модулей отрицательных — 367.22;
- их сумма — 410.54.
Заметьте асимметрию: негатив в сумме весит почти в девять раз больше позитива. Это осознанное решение — стоимость показать неприятное сильно выше выгоды показать приятное.
3. Формула и что происходит с отрицательным скором
Арифметика в ranking_scorer.rs устроена прямолинейно: каждое слагаемое — вес на предсказанную вероятность, положительные и отрицательные копятся раздельно только для отчётности, а дальше берётся разность.
let mut pos = 0.0;
let mut neg = 0.0;
for t in terms {
if t >= 0.0 { pos += t; } else { neg -= t; }
}
(pos, neg)
Фрагмент из ranking_scorer.rs · код X, Apache 2.0, коммит 28e414f
То есть сырой скор — это просто
$$ S_{\text{raw}} \;=\; \sum_i w_i \cdot P(\text{действие}_i), $$где веса негативных действий уже отрицательные. Но напрямую это число не используется: оно пропускается через offset_score.
pub(crate) fn offset_score(combined_score: f64, w: &ScoringWeights) -> f64 {
if w.total_sum == 0.0 {
combined_score.max(0.0)
} else if combined_score < 0.0 {
(combined_score + w.negative_sum) / w.total_sum * NEGATIVE_SCORES_OFFSET
} else {
combined_score + NEGATIVE_SCORES_OFFSET
}
}
Фрагмент из ranking_scorer.rs · код X, Apache 2.0, коммит 28e414f
Разберём, что это делает
Константа NEGATIVE_SCORES_OFFSET равна 0.001. Разберём обе ветки.
Скор неотрицательный. К нему просто прибавляется 0.001. Значит, любой такой пост получает итог не меньше 0.001.
Скор отрицательный. Он линейно отображается по формуле
$$ S_{\text{final}} \;=\; \frac{S_{\text{raw}} + 367.22}{410.54} \cdot 0.001. $$Посмотрим на границы. Самый плохой возможный пост — тот, у которого все негативные действия имеют вероятность 1, а позитивные 0. Его сырой скор равен \(-367.22\), и формула даёт ровно 0. Скор, чуть-чуть не дотянувший до нуля, даёт почти \(\frac{367.22}{410.54} \cdot 0.001 \approx 0.000894\).
Она решает три задачи сразу.
- Скор всегда неотрицателен. Это важно, потому что дальше по конвейеру он умножается на поправочные множители меньше единицы. Умножить отрицательное число на 0.25 значит поднять его — то есть штраф превратился бы в награду. Загнав всё в положительную область, эту ловушку закрывают на уровне арифметики.
- Порядок сохраняется полностью. Отрицательные скоры лежат в \([0;\,0.000894]\), положительные — от \(0.001\) и выше. Ни один «плохой» пост не может обогнать ни одного «хорошего», а внутри каждой группы относительный порядок не меняется: обе ветки монотонны.
- Плохие посты сжаты в узкую полосу. Разница между «слегка плохим» и «катастрофически плохим» — тысячные доли. Это осознанно: как только пост ушёл в минус, детали уже не важны, он всё равно окажется в конце.
Обратите внимание, насколько это похоже на то, что мы обсуждали в метриках ранжирования: важен порядок, а не абсолютные значения. Здесь абсолютные значения деформируют сознательно, лишь бы порядок остался прежним и арифметика дальше не сломалась.
4. Главное заблуждение: «жалоба перечёркивает 468 лайков»
Вес жалобы \(-234\), вес лайка \(0.5\). Отношение — 468. Число облетело интернет с выводом: одна жалоба уничтожает эффект от 468 лайков.
Вывод неверен, и разработчики специально добавили в код комментарий об этом. Причина простая: вес умножается не на количество событий, а на предсказанную вероятность.
// Each weight multiplies the *predicted* probability of that
// ... the weights do not multiply raw engagement counts.
// One common misinterpretation is that you can read these weight
// ...
fn apply(score: Option<f64>, weight: f64) -> f64 {
score.unwrap_or(0.0) * weight
}
Фрагмент из ranking_scorer.rs · код X, Apache 2.0, коммит 28e414f
Разберём на числах. Пусть для обычного поста модель считает, что вы лайкнете его с вероятностью 0.02, а пожалуетесь — с вероятностью 0.00005. Тогда вклады:
- лайк: \(0.5 \times 0.02 = +0.010\);
- жалоба: \(-234 \times 0.00005 = -0.0117\).
Жалоба перевешивает лайк в 1.17 раза, а не в 468. Отношение 468 достигается только в одном случае: если модель считает жалобу такой же вероятной, как лайк. Но если модель так думает, это уже не обычный пост, и его место в конце ленты — ровно то, чего и добивались.
- Пресеты сверху задают правдоподобные наборы вероятностей. На обычном посте вклад жалобы сопоставим со вкладом лайка — отношение около 1.2, а не 468.
- Возьмите ползунок жалобы и подтяните её вероятность к вероятности лайка. Отношение поедет к 468, а сырая сумма провалится глубоко в минус. Это и есть условие, при котором расхожая интерпретация становится верной.
- Пресет «токсичный»: сырая сумма отрицательна, и финальный скор схлопывается в 0.0009 — та самая узкая полоса под 0.001, куда сжимаются все отрицательные посты.
- Пресет «скучный» интереснее токсичного: жалоб нет вовсе, но «не задержался» с вероятностью 0.72 при весе всего −0.02 плюс слабые позитивы дают отрицательную сумму. Ленту чистит не только явный негатив.
Что сказать на собесе: «Веса умножаются на предсказанные вероятности, а не на счётчики. Отношение весов задаёт лишь, во сколько раз действие важнее при равной вероятности; фактический вклад определяется произведением».
Из того же факта следует ответ на вопрос «а можно ли завалить чужой пост массовыми жалобами». Прямо — нет, и вот почему.
- В скор входит ваша предсказанная вероятность пожаловаться, а не чужие жалобы. Действия других людей влияют на неё лишь настолько, насколько модель считает вас на них похожим.
- Рекомендации персонализированы: если на пост жалуется группа с определённым поведением, эффект в первую очередь скажется на выдаче тем, кто на эту группу похож.
- Чтобы действие вообще попало в обучающие данные ранжирования, оно должно произойти на посте, показанном в ленте. Прийти по прямой ссылке и нажать кнопку — не то же самое.
Это, кстати, хороший пример того, о чём говорилось в главе «Сводка смещений»: система, обучающаяся на собственной выдаче, устойчива к манипуляциям снаружи ровно в той мере, в какой она игнорирует события вне своей выдачи.
5. Три поправки поверх скора
Сырая сумма — ещё не финальный порядок. Дальше идут три множителя, и порядок их применения в коде такой: сначала буст малоизвестного автора, потом затухание по повторному автору, потом OON-дисконт.
Затухание по повторному автору
Задача очевидная: не дать одному автору занять всю ленту. Решение в коде — множитель, зависящий от того, сколько постов этого автора уже стоит выше по скору.
fn diversity_multiplier(decay_factor: f64, floor: f64, exponent: f64) -> f64 {
(1.0 - floor) * decay_factor.powf(exponent) + floor
}
Фрагмент из ranking_scorer.rs · код X, Apache 2.0, коммит 28e414f
Подставим:
| Какой по счёту пост автора | \(k\) | Множитель |
|---|---|---|
| первый | 0 | 1.000 |
| второй | 1 | 0.625 |
| третий | 2 | 0.438 |
| четвёртый | 3 | 0.344 |
| десятый | 9 | 0.251 |
| предел | →∞ | 0.250 |
Форма формулы стоит того, чтобы её разобрать. Это не просто \(\text{decay}^k\) — иначе множитель уходил бы в ноль, и достаточно активный автор исчезал бы из ленты полностью. Конструкция \((1-\text{floor}) \cdot \text{decay}^k + \text{floor}\) — это геометрическое затухание, приподнятое на пол: быстро падает на первых повторах и упирается в 0.25.
Альтернатива напрашивается: просто не пускать больше \(N\) постов одного автора. Так делают, и это называется квотой. Но у мягкого множителя есть преимущество, которое видно именно на этом коде.
Квота — это решение, принятое заранее и одинаковое для всех. Множитель — это цена: пятый пост автора всё ещё может пройти, если он настолько хорош, что даже с коэффициентом 0.25 обгоняет чужие. Если человек подписан на трёх авторов и один из них сегодня написал что-то выдающееся, жёсткая квота отрежет хорошее, а множитель — пропустит.
Тот же аргумент про мягкие и жёсткие ограничения мы разбирали, говоря о разнообразии: MMR штрафует за похожесть, а не запрещает её.
Дисконт за пределы подписок
Посты от тех, на кого зритель не подписан, умножаются на 0.75. Логика: такой пост должен быть заметно лучше «своего», чтобы занять его место, потому что риск промаха выше.
Но интереснее не сам множитель, а условие его применения:
let oon_applies = |c: &PostCandidate| match c.in_network {
Some(false) => true,
Some(true) => {
deboost_in_network_replies_retweets
&& (c.in_reply_to_tweet_id.is_some() || c.retweeted_tweet_id.is_some())
}
None => false,
};
Фрагмент из ranking_scorer.rs · код X, Apache 2.0, коммит 28e414f
Дисконт получают не только чужие посты, но и ответы и репосты от тех, на кого вы подписаны (флаг включён по умолчанию). Это тонкий и правильный ход: подписавшись на человека, вы согласились читать его посты, а не всё, что он комментирует и пересылает. Без этой строки лента подписок быстро превратилась бы в поток чужих разговоров.
Есть ещё два случая:
- если запрос идёт по конкретной теме, множитель другой — 0.5: в тематической ленте подписки значат меньше, а требования к чужому посту жёстче;
- для новых пользователей с достаточным числом подписок действует отдельный, более мягкий множитель — очевидная поправка на холодный старт зрителя: подписок ещё мало, и без чужих постов ленты просто не будет.
Подъём малоизвестного автора
Здесь механика устроена совсем не так, как обычно предполагают, и это стоит разобрать внимательно.
Это не множитель и применяется не ко всем постам новичков. Из всех кандидатов отбираются подходящие: автор с числом подписчиков ниже порога (1000), пост с числом показов ниже порога (1000), достаточно свежий (не старше суток) и стоящий не слишком низко в текущем порядке. Из них берётся ровно один — лучший по скору, — и его скор приравнивается к тому, что стоит на позиции 15–16 отсортированного списка.
fn cold_start_target(query: &ScoredPostsQuery, scores: &[f64]) -> Option<f64> {
let mut ranked = scores.to_vec();
ranked.sort_by(|a, b| b.total_cmp(a));
let hi = (query.params.get(ColdStartSlotMax) as usize).min(ranked.len());
let lo = (query.params.get(ColdStartSlotMin) as usize).min(hi);
if lo >= hi { return None; }
Some(ranked[rand::rng().random_range(lo..hi)])
}
Фрагмент из author_cold_start.rs · код X, Apache 2.0, коммит 28e414f
Читается прозрачно: цель — не первая позиция, а середина видимой части ленты. Смысл не «сделать новичка звездой», а «дать одному его посту шанс быть увиденным», чтобы система вообще получила по нему сигнал.
Ситуация классическая: у поста мало показов, значит, оценка его качества основана на малой выборке и очень неточна. Показать его — потратить позицию, но получить информацию. Не показывать — навсегда остаться в неведении и закрепить холодный старт.
И в коде это признано явно. Рядом лежит альтернативный способ выбора кандидата — сэмплирование Томпсона с априором \(\text{Beta}(0.75,\ 49.25)\), выбирающее не лучший по скору пост, а случайный пропорционально вероятности быть лучшим. Параметры сохранены в конфиге, но по умолчанию этот путь выключен, и работает простой выбор лучшего.
Априор \(\text{Beta}(0.75,\ 49.25)\) читается как «мы заранее считаем, что типичный пост нового автора получает отклик примерно в 1.5% случаев» — сумма параметров 50 задаёт, насколько сильно мы держимся за это убеждение, пока не накопились данные. Это в точности сглаженный CTR из тренажёра и Thompson sampling, встреченные в живом коде.
- Выключите затухание по автору: топ-5 мгновенно захватывает один автор — тот, чьи посты модель оценивает высоко. Включите обратно — в топе становится три-четыре разных автора. Это самая заметная из трёх поправок.
- Опустите floor в ноль: множитель для четвёртого поста падает с 0.344 до 0.125, и активный автор практически исчезает после второго поста. Видно, зачем нужен пол.
- Найдите строку с пометкой «поднят на позицию 15–16». Это тот самый единственный пост малоизвестного автора. Посмотрите, с какой позиции он пришёл.
- Деталь, которую видно только в коде: цель буста считается по скорам до затухания и дисконта. Эти поправки потом опускают остальных, а забустенный пост под них обычно не подпадает — он от уникального автора и часто оказывается выше, чем позиция, на которую его целили. Фактический эффект буста сильнее, чем читается из параметров.
Что сказать на собесе: «Разнообразие по авторам реализовано мягким множителем с полом, а не квотой: очень хороший пост может пройти и пятым от того же автора. Поддержка новых авторов — это подъём одного поста в середину ленты, то есть явный exploration ради получения сигнала».
6. Переранжирование: DPP по эмбеддингам
После того как скор посчитан и поправки применены, вызывается отдельный сервис. Он отбирает посты детерминантным точечным процессом — тем самым DPP, который мы разбирали в главе «Разнообразие и переранжирование» рядом с MMR.
Напомним идею. Строится ядро \(L\), в котором качество айтема стоит на диагонали, а похожесть — вне её. Максимизируется определитель подматрицы выбранного набора, а определитель — это квадрат объёма параллелепипеда на векторах набора. Длина ребра задаётся качеством, угол между рёбрами — непохожестью. Два почти одинаковых поста дают почти коллинеарные рёбра, объём схлопывается — и дубликат отсеивается сам, без отдельного штрафного слагаемого.
Параметры, с которыми это вызывается, лежат в двух местах, и это стоит различать:
| Параметр | Значение у сервиса | Значение, присылаемое лентой | Что задаёт |
|---|---|---|---|
dpp_theta | 0.5 | 0.65 | Баланс между качеством и непохожестью |
dpp_max_selected_rank | 100 | 150 | Докуда по списку разрешено переставлять |
dpp_top_k | 50 | — | Сколько позиций отбирается процессом |
embedding_dim | 1024 | — | Размерность эмбеддингов постов |
Первый столбец — дефолты аргументов самого сервиса, второй — параметры, которые присылает лента. Побеждает вызывающая сторона, поэтому в проде работают 0.65 и 150. Разночтение не ошибка: сервис самостоятельный, у него свои дефолты на случай запуска отдельно.
Ограничение max_selected_rank заслуживает внимания. Переранжирование не трогает весь список — только первые 150 позиций. Дальше нет смысла: пользователь туда не дойдёт, а считать определители дорого.
Мы уже отмечали на странице обзора, что кандидаты в ранжирующем трансформере намеренно не видят друг друга. Значит, модель принципиально не может учесть, что три поста подряд — про одно и то же: она оценивает каждый в отдельности.
Разнообразие поэтому вынесено в отдельный шаг после скоринга, и это разумный размен. Модель остаётся консистентной и кэшируемой, а списочные эффекты обрабатываются там, где их проще контролировать: отдельным сервисом с двумя понятными ручками, которые можно крутить экспериментом, не переобучая ничего.
Заодно обратите внимание, что разнообразие здесь наводится дважды и по-разному: затухание по автору — про то, чтобы не залипнуть на одном человеке, DPP по эмбеддингам — про то, чтобы не залипнуть на одной теме. Это разные вещи, и одно другое не заменяет.
Частые ошибки и подводные камни
- Читать отношение весов как отношение влияния. Влияние — это \(w \cdot p\). Отношение весов работает только при равных вероятностях.
- Думать, что нулевой вес значит «голову выкинули». У клика по профилю и задержки вес 0.0: головы обучаются, значения предсказываются, но в скор сейчас не входят. Это положение ручки, а не отсутствие ручки, — и завтра оно может стать ненулевым.
- Забывать про
offset_score. Финальный скор никогда не отрицателен, и это не косметика: дальше он умножается на множители меньше единицы, и отрицательное число превратило бы штраф в награду. - Считать, что буст новичка — это множитель. Это подъём одного поста к скору позиции 15–16.
- Считать OON-дисконт наказанием за «чужие» посты. Он применяется и к ответам с репостами тех, на кого вы подписаны, — то есть это в равной степени защита ленты подписок от чужих разговоров.
- Путать затухание по автору и DPP. Первое борется с однообразием по людям, второе — по темам. В системе есть оба.
Вопросы с собеседований
Почему нельзя сказать, что жалоба перечёркивает 468 лайков?
Потому что вес умножается на предсказанную вероятность действия, а не на количество произошедших действий. На обычном посте вероятность жалобы на два-три порядка меньше вероятности лайка, поэтому фактические вклады сопоставимы: примерно \(-0.012\) против \(+0.010\).
Отношение 468 достигается ровно тогда, когда модель считает жалобу столь же вероятной, как лайк. Но такой пост и должен уходить вниз — механизм в этом случае отрабатывает как задумано, а не как несправедливость.
Зачем финальный скор загоняют в неотрицательную область?
Потому что дальше он умножается на поправки меньше единицы: затухание по автору, дисконт за пределы подписок. Умножение отрицательного числа на 0.25 увеличивает его — штраф превратился бы в награду, и повторный пост плохого автора поднимался бы вверх.
Отображение устроено так, что порядок сохраняется полностью: отрицательные скоры сжимаются в \([0;\,0.000894]\), положительные начинаются с 0.001. Хороший пост не может оказаться ниже плохого.
Как реализовано разнообразие по авторам и чем это лучше квоты?
Множителем \(m(k) = (1-\text{floor})\cdot\text{decay}^{k} + \text{floor}\) с параметрами 0.5 и 0.25, где \(k\) — число постов того же автора выше по скору. Получается 1.0, 0.625, 0.438, 0.344 и так далее с полом 0.25.
Преимущество перед квотой: это цена, а не запрет. Достаточно хороший пятый пост автора всё ещё может пройти, если обгоняет чужие даже с коэффициентом 0.25. Жёсткая квота отрезала бы его независимо от качества. Пол нужен, чтобы автор не исчезал совсем — без него множитель уходил бы в ноль.
Почему ответы и репосты от людей, на которых вы подписаны, получают дисконт?
Потому что подписка — это согласие читать посты человека, а не всё, что он комментирует и пересылает. Без такого дисконта лента подписок заполнялась бы чужими разговорами, попавшими туда через одного знакомого.
Формально это тот же множитель 0.75, что и для постов вне подписок; в коде условие объединено. Управляется отдельным флагом, включённым по умолчанию.
Что происходит с постами новых авторов и при чём тут бандиты?
Один пост, проходящий по порогам (мало подписчиков у автора, мало показов у поста, свежий), поднимается до скора позиции 15–16. Это явный exploration: тратим позицию, чтобы получить сигнал о посте, о котором данных нет.
В коде рядом лежит альтернатива — выбирать этот пост сэмплированием Томпсона с априором \(\text{Beta}(0.75,\ 49.25)\) вместо «взять лучший по скору». По умолчанию она выключена, но сама постановка признана бандитской явно. Априор соответствует ожидаемому отклику около 1.5% с силой убеждения в 50 наблюдений.
Зачем нужен DPP, если разнообразие уже наводится затуханием по автору?
Это разные виды однообразия. Затухание не даёт одному человеку занять ленту. DPP по эмбеддингам не даёт занять её одной теме — даже если посты написаны разными авторами.
Плюс DPP решает задачу, недоступную самой модели: кандидаты в трансформере не видят друг друга, поэтому модель принципиально не знает, что три поста про одно и то же. Списочные эффекты вынесены в отдельный шаг, где ими можно управлять двумя параметрами без переобучения.
Как вы поймёте, что вес выбран правильно?
Никак не поймёте аналитически — веса подбираются экспериментами. Это и есть главная цена мультизадачной схемы: каждое изменение весов требует A/B-теста, а тестов на всю решётку значений не хватит.
На практике смотрят не на вовлечение в моменте, а на долгосрочные показатели — возвращаемость, время до следующей сессии, доля негативных реакций. Это ровно тот разговор про прокси-метрики и их расхождение с настоящей целью, который был в главе «Прокси-метрики и долгосрочные цели» и глава «Истинная релевантность и её прокси». Тот факт, что вес жалобы настолько велик, а сумма негативных весов в девять раз превышает сумму позитивных, — как раз попытка защититься от оптимизации сиюминутного вовлечения.
Шпаргалка одним экраном
Формула
\(S = \sum_i w_i \cdot P(\text{действие}_i)\), затем offset_score, затем три поправки.
Веса-рекордсмены
Копирование ссылки +20, ответ +5, лайк +0.5. Жалоба −234, скрыть автора −58.8.
Суммы
Позитивные 43.32, негативные 367.22, вместе 410.54. Негатив весит в девять раз больше.
offset_score
\(S \ge 0 \to S + 0.001\); \(S < 0 \to \frac{S + 367.22}{410.54}\cdot 0.001\). Всё неотрицательно, порядок цел.
Затухание по автору
\((1-0.25)\cdot 0.5^{k} + 0.25\): 1.0 → 0.625 → 0.438 → 0.344, пол 0.25.
OON-дисконт
×0.75 для чужих постов и для ответов/репостов своих. В тематической ленте ×0.5.
Буст новичка
Один пост, поднятый до скора позиции 15–16. Пороги: 1000 подписчиков, 1000 показов, сутки.
DPP
θ = 0.65, переставляет только первые 150 позиций, эмбеддинги размерности 1024.
Порядок применения
Буст новичка → затухание по автору → OON-дисконт → DPP.
Первоисточники
- home-mixer/params/param.rs — все веса и параметры с комментариями авторов о том, как их читать.
- home-mixer/scorers/ranking_scorer.rs — арифметика скора,
offset_score, затухание, OON. - home-mixer/scorers/author_cold_start.rs — подъём малоизвестного автора и сэмплирование Томпсона.
- vm-ranker/dpp.rs и args.rs — переранжирование и его параметры.
- Курс: глава «Мультизадачность» о мультизадачности, глава «Разнообразие и переранжирование» о MMR и DPP, глава «Thompson sampling» о сэмплировании Томпсона.