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

Дополнение · 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\).

Зачем такая конструкция

Она решает три задачи сразу.

  1. Скор всегда неотрицателен. Это важно, потому что дальше по конвейеру он умножается на поправочные множители меньше единицы. Умножить отрицательное число на 0.25 значит поднять его — то есть штраф превратился бы в награду. Загнав всё в положительную область, эту ловушку закрывают на уровне арифметики.
  2. Порядок сохраняется полностью. Отрицательные скоры лежат в \([0;\,0.000894]\), положительные — от \(0.001\) и выше. Ни один «плохой» пост не может обогнать ни одного «хорошего», а внутри каждой группы относительный порядок не меняется: обе ветки монотонны.
  3. Плохие посты сжаты в узкую полосу. Разница между «слегка плохим» и «катастрофически плохим» — тысячные доли. Это осознанно: как только пост ушёл в минус, детали уже не важны, он всё равно окажется в конце.

Обратите внимание, насколько это похоже на то, что мы обсуждали в метриках ранжирования: важен порядок, а не абсолютные значения. Здесь абсолютные значения деформируют сознательно, лишь бы порядок остался прежним и арифметика дальше не сломалась.

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. Тогда вклады:

Жалоба перевешивает лайк в 1.17 раза, а не в 468. Отношение 468 достигается только в одном случае: если модель считает жалобу такой же вероятной, как лайк. Но если модель так думает, это уже не обычный пост, и его место в конце ленты — ровно то, чего и добивались.

Что здесь надо увидеть
  1. Пресеты сверху задают правдоподобные наборы вероятностей. На обычном посте вклад жалобы сопоставим со вкладом лайка — отношение около 1.2, а не 468.
  2. Возьмите ползунок жалобы и подтяните её вероятность к вероятности лайка. Отношение поедет к 468, а сырая сумма провалится глубоко в минус. Это и есть условие, при котором расхожая интерпретация становится верной.
  3. Пресет «токсичный»: сырая сумма отрицательна, и финальный скор схлопывается в 0.0009 — та самая узкая полоса под 0.001, куда сжимаются все отрицательные посты.
  4. Пресет «скучный» интереснее токсичного: жалоб нет вовсе, но «не задержался» с вероятностью 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

$$ m(k) \;=\; (1 - \text{floor}) \cdot \text{decay}^{\,k} + \text{floor}, \qquad \text{decay} = 0.5,\ \ \text{floor} = 0.25. $$

Подставим:

Какой по счёту пост автора\(k\)Множитель
первый01.000
второй10.625
третий20.438
четвёртый30.344
десятый90.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

Дисконт получают не только чужие посты, но и ответы и репосты от тех, на кого вы подписаны (флаг включён по умолчанию). Это тонкий и правильный ход: подписавшись на человека, вы согласились читать его посты, а не всё, что он комментирует и пересылает. Без этой строки лента подписок быстро превратилась бы в поток чужих разговоров.

Есть ещё два случая:

Подъём малоизвестного автора

Здесь механика устроена совсем не так, как обычно предполагают, и это стоит разобрать внимательно.

Это не множитель и применяется не ко всем постам новичков. Из всех кандидатов отбираются подходящие: автор с числом подписчиков ниже порога (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, встреченные в живом коде.

Что здесь надо увидеть
  1. Выключите затухание по автору: топ-5 мгновенно захватывает один автор — тот, чьи посты модель оценивает высоко. Включите обратно — в топе становится три-четыре разных автора. Это самая заметная из трёх поправок.
  2. Опустите floor в ноль: множитель для четвёртого поста падает с 0.344 до 0.125, и активный автор практически исчезает после второго поста. Видно, зачем нужен пол.
  3. Найдите строку с пометкой «поднят на позицию 15–16». Это тот самый единственный пост малоизвестного автора. Посмотрите, с какой позиции он пришёл.
  4. Деталь, которую видно только в коде: цель буста считается по скорам до затухания и дисконта. Эти поправки потом опускают остальных, а забустенный пост под них обычно не подпадает — он от уникального автора и часто оказывается выше, чем позиция, на которую его целили. Фактический эффект буста сильнее, чем читается из параметров.

Что сказать на собесе: «Разнообразие по авторам реализовано мягким множителем с полом, а не квотой: очень хороший пост может пройти и пятым от того же автора. Поддержка новых авторов — это подъём одного поста в середину ленты, то есть явный exploration ради получения сигнала».

6. Переранжирование: DPP по эмбеддингам

После того как скор посчитан и поправки применены, вызывается отдельный сервис. Он отбирает посты детерминантным точечным процессом — тем самым DPP, который мы разбирали в главе «Разнообразие и переранжирование» рядом с MMR.

Напомним идею. Строится ядро \(L\), в котором качество айтема стоит на диагонали, а похожесть — вне её. Максимизируется определитель подматрицы выбранного набора, а определитель — это квадрат объёма параллелепипеда на векторах набора. Длина ребра задаётся качеством, угол между рёбрами — непохожестью. Два почти одинаковых поста дают почти коллинеарные рёбра, объём схлопывается — и дубликат отсеивается сам, без отдельного штрафного слагаемого.

Параметры, с которыми это вызывается, лежат в двух местах, и это стоит различать:

ПараметрЗначение у сервисаЗначение, присылаемое лентойЧто задаёт
dpp_theta0.50.65Баланс между качеством и непохожестью
dpp_max_selected_rank100150Докуда по списку разрешено переставлять
dpp_top_k50Сколько позиций отбирается процессом
embedding_dim1024Размерность эмбеддингов постов

Первый столбец — дефолты аргументов самого сервиса, второй — параметры, которые присылает лента. Побеждает вызывающая сторона, поэтому в проде работают 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.

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