Все 202 вопросов учебника подряд. Сначала отвечаете сами, потом раскрываете ответ
и помечаете «знаю» или «повторить» — отметки сохраняются в браузере.
всеЧасть I · 6Часть I · 6Часть I · 6Часть I · 6Часть II · 5Часть II · 7Часть II · 7Часть II · 6Часть II · 6Часть III · 8Часть III · 9Часть III · 8Часть III · 8Часть IV · 8Часть IV · 8Часть IV · 9Часть V · 8Часть V · 8Часть VI · 9Дополнение · 6Дополнение · 5Дополнение · 5Дополнение · 7Дополнение · 7Дополнение · 7Дополнение · 5Дополнение · 5Дополнение · 4Дополнение · 4Дополнение · 9
Часть I · Задача: почему рекомендации устроены именно так
Что такое длинный хвост и как измерить, насколько он длинный?
Спрос распределён по степенному закону: частота \(k\)-го по популярности объекта пропорциональна \(k^{-\alpha}\). В логарифмических осях это прямая с наклоном \(-\alpha\), так его и оценивают.
Одной картинки недостаточно, надо называть показатель. Про данные — \(\alpha\) или Джини по частотам. Про выдачу — coverage@k (доля каталога, показанная хотя бы раз), Джини по показам и энтропия показов. Разница важна: система может как усугубить перекос, так и сгладить.
Порядок величин: при \(\alpha = 1.0\) верхний процент каталога собирает около 62% взаимодействий, при \(\alpha = 1.2\) — уже 85%.
Часть I · Задача: почему рекомендации устроены именно так
Почему длинный хвост делает холодный старт неизбежным?
Это прямое следствие арифметики. При \(\alpha = 1.2\), каталоге в 100 тысяч и десяти миллионах взаимодействий у 74% айтемов ожидаемо меньше десяти взаимодействий. При \(\alpha = 1.0\) — у 17%.
То есть большая часть каталога всегда находится в состоянии, где коллаборативного сигнала практически нет, и это не проблема молодого сервиса, а свойство распределения спроса. Отсюда необходимость контентных признаков: они позволяют говорить об айтеме до того, как он накопил историю.
Часть I · Задача: почему рекомендации устроены именно так
Как бизнес-модель влияет на целевую функцию рекомендаций?
Подписка платит за удержание: важна вероятность продления, а не просмотры. Метрика медленная, цикл обратной связи — месяц.
Реклама платит за внимание: показы, время, возвраты. Здесь встроен конфликт — удерживающее внимание не обязано быть полезным.
Маркетплейс платит комиссией с оборота, но с двумя поправками: возвраты и здоровье предложения. Продавцу нужен спрос, иначе он уйдёт, и площадка останется без ассортимента.
Практический вывод: подставить чужую метрику — самая дорогая ошибка постановки. И в двухстороннем рынке это задача с ограничениями, которую на практике сводят к взвешенной сумме с весами из экспериментов.
Часть I · Задача: почему рекомендации устроены именно так
Почему оптимизация прокси-метрики со временем портит продукт?
У прокси и настоящей цели есть общая часть и расхождение. При слабом давлении они идут рядом; когда начинают давить, модель находит именно расхождение — там дешевле всего добрать метрику.
Отсюда кликбейт при оптимизации кликов, бесконечный скролл при оптимизации времени, возвраты при оптимизации немедленного GMV.
Лечение — не отказ от прокси, а связка: быстрые прокси двигают систему, медленные метрики здоровья (удержание когорт, доля активных на горизонте месяцев, жалобы, возвраты) её охраняют. Кривую удержания при этом читают по форме: здоровый продукт выполаживается на ненулевом уровне.
Часть I · Задача: почему рекомендации устроены именно так
Почему рекомендательная система многостадийная, а не одна большая модель?
Бюджет не сходится. При каталоге в миллион и стоимости скора 100 мкс полный перебор — 100 секунд против бюджета в 100 мс, то есть в тысячу раз больше. Константами такой разрыв не закрывается.
Поэтому: дёшево сузить (кандидатогенерация, сотни из миллионов, обычно скалярное произведение в индексе), затем дорого упорядочить (ранжирование на сотнях). Иногда добавляют pre-ranking между ними и переранжирование поверх топа.
Задержка при этом не абстракция: замедление измеримо стоит денег — известное наблюдение про 100 мс и процент продаж восходит к внутренним экспериментам Amazon, о которых рассказывал Грег Линден в 2006 году.
Часть I · Задача: почему рекомендации устроены именно так
Какой метрикой мерить кандидатогенерацию и почему не точностью?
Полнотой. Задача первой стадии — не угадать ответ, а не потерять его: чего она не отобрала, того ранжирование уже не покажет.
Отсюда следует, что сквозное качество ограничено сверху полнотой первой стадии. Даже идеальное ранжирование не поднимет систему выше этого потолка, поэтому улучшать надо самую слабую стадию, а не самую заметную.
Точность на этой стадии не только бесполезна, но и вредна как ориентир: она толкает отбирать поменьше и понадёжнее, то есть ровно в популярное, обрушая покрытие.
Почему в продакшене учат на неявном фидбеке, а не на оценках?
Явного фидбека мало (единицы процентов пользователей), он смещён по составу (оценивают крайности, а не середину, которая составляет основную массу потребления), смещён по людям (оценки ставит особая активная подвыборка) и расходится с поведением — человек оценивает, каким хочет быть.
Неявный фидбек — покупки, клики, время просмотра — обильный, бесплатный для пользователя и ближе к реальному поведению. Цена: четыре проблемы, начиная с того, что он весь обусловлен предыдущей политикой показов.
Что на самом деле предсказывает модель, обученная на логах?
Не \(P(\text{click})\), а \(P(\text{click} \mid \text{show}, \pi_{\text{log}})\), где \(\pi_{\text{log}}\) — логирующая политика: предыдущая модель плюс все фильтры и бизнес-правила, стоявшие в проде.
Это структурное свойство, а не погрешность: в логах физически нет данных о том, чего система не показывала. Дальнейшая работа со смещениями сводится к двум вариантам — либо учесть это условие в оценке (IPS и родственники), либо изменить саму \(\pi_{\text{log}}\) через exploration.
Почему нельзя брать случайные айтемы каталога негативами для ранкера?
Потому что ранкер никогда не применяется к случайным айтемам. Он работает на сотне кандидатов, которые отобрала первая стадия, и отличать их от случайного шума — задача, которой в проде не существует.
Правило: пул ранкера строится из слейтов. Позитивы — то, с чем было взаимодействие; негативы — показанное рядом и не выбранное. Случайные айтемы каталога — законные негативы для кандидатогенерации и двухбашенных моделей, потому что те как раз применяются ко всему каталогу.
И отдельно: отсутствие клика по непоказанному айтему — это неизвестность, а не отказ. Ставить туда ноль значит выдумывать данные.
Перечислите основные смещения и чем они отличаются по механизму.
Selection — выбирал пользователь: он сам решает, что оценивать, и оценивает крайности. Exposure — выбирала система: в логах только показанное. Positional — испортила позиция: клик вероятнее наверху независимо от содержания. Popularity — испортил объём: у популярного данных на порядки больше.
Полезное различение: первые три портят оценку (мы неверно измеряем то, что есть), popularity портит обучение (хвост не на чем выучить).
Отдельный случай — popularity, встроенный в геометрию: у скалярного произведения норма не сокращается, а в обучении она растёт с популярностью, поэтому MIPS систематически предпочитает популярное. Лечится нормировкой или коррекцией в лоссе.
Почему позиционное смещение нельзя вылечить моделью?
Потому что оно уже в таргете. Клик собран под влиянием позиции, и любая модель, обученная на этой разметке, воспроизведёт смещение — оно для неё часть сигнала.
Три уровня работы, по возрастанию стоимости: смягчить top-heavy метриками (дёшево, но не убирает); подать позицию признаком и подставлять единицу на инференсе (просто, но модель может спрятать релевантность в позиционный признак); оценить вероятность просмотра и разделить эффекты — отдельная башня смещения или IPS-взвешивание, что честнее всего, но требует случайного трафика для оценки propensity.
Как быстро замыкается feedback loop и как это заметить?
Быстрее, чем ожидают. В симуляции с каталогом на 2000 айтемов и десятью слотами покрытие останавливается на 0.6% — двенадцати айтемах — уже после первого раунда, и двенадцать последующих раундов его не меняют. Качество замирает на 0.37 от достижимого.
Причём модель при этом не сломана: она отлично обучена на том, что видела. Случайная квота в 5% поднимает покрытие до 24.8%, а качество до 0.70 — ценой того, что эти 5% показов заведомо хуже.
Ловят дешёвыми метриками во времени: coverage и Джини по показам, доля показов у топ-1% каталога, медианный возраст показанного. Плюс доля процента полностью случайного трафика — это несмещённая выборка, на которой можно честно измерить, насколько всё плохо.
Часть I · Метрики: чем меряют кандидатогенерацию и ранжирование
Почему кандидатогенерацию меряют полнотой, а ранжирование — нет?
Потому что задачи разные. Первая стадия должна не потерять: чего она не отобрала, того вторая уже не покажет, и ошибка невосстановима. Вторая должна расставить: её ошибка восстановима переранжированием.
Мерить кандидатогенератор точностью вредно: точность толкает отбирать поменьше и понадёжнее, то есть в популярное, обрушая покрытие. А сквозное качество всё равно ограничено сверху полнотой первой стадии.
Часть I · Метрики: чем меряют кандидатогенерацию и ранжирование
Почему Recall@K нельзя сравнивать при фиксированном K?
Потому что K — это уже инженерное решение о размере выдачи, а источники стоят по-разному: статический топ из памяти почти бесплатен, ANN-индекс стоит миллисекунды, нейросетевой источник заметно больше.
Сравнивать надо полноту относительно бюджета латентности: строить парето-фронт «полнота против времени» и брать точку под свой бюджет. Источник с меньшей полнотой, но втрое дешевле, часто выигрывает — на сэкономленное время добавляется ещё один источник.
Отдельно стоит уточнить знаменатель: считаем относительно позитивов датасета (дёшево, но завышено — позитивы собраны прошлой политикой) или совместно с ранжированием (честнее, но нестабильно во времени).
Часть I · Метрики: чем меряют кандидатогенерацию и ранжирование
Чем NDCG лучше MAP и Precision@k?
Precision@k вообще не видит порядка: три раскладки одного слейта с хитами наверху, в середине и внизу дают одинаковые 0.300, тогда как NDCG — 1.0000, 0.5508 и 0.4250.
MAP порядок учитывает, но бинарна по природе: градуированную релевантность она не выражает. NDCG умеет и градуированный gain (включая экспоненциальный \(2^{rel}-1\) или прямо цену позитива), и явный дисконт по позиции, и нормировку на идеальный порядок, которая делает запросы с разным числом позитивов сравнимыми.
Оговорка: логарифмический дисконт — модельное допущение о внимании, а не измеренная величина.
Часть I · Метрики: чем меряют кандидатогенерацию и ранжирование
Почему одна инверсия не равна другой инверсии?
Из-за дисконта. Перестановка соседних позиций 1 и 2 меняет NDCG на 0.3691, та же перестановка на позициях 9 и 10 — на 0.0120. Разница в 31 раз.
Отсюда следует, что попарный лосс, считающий все инверсии одинаковыми (RankNet), оптимизирует не то, что мы меряем. Исправление — домножить градиент пары на то, насколько её перестановка сдвинет метрику; так устроен LambdaRank.
Часть I · Метрики: чем меряют кандидатогенерацию и ранжирование
Что AUC не показывает и когда это критично?
Во-первых, AUC считает все пары равноценными: перестановка на первой позиции стоит столько же, сколько на двухсотой. Для выдачи, где видны первые десять, это ровно та слепота, из-за которой нужна NDCG.
Во-вторых, AUC инвариантен к любому монотонному преобразованию скора и потому ничего не говорит о калибровке. Модель может систематически завышать вероятности вдвое при отличном AUC.
Критично там, где скор попадает в арифметику: рекламный аукцион со ставкой × CTR, распределение бюджета, пороговые правила. Там смотрят LogLoss, Brier, ECE и калибровочную кривую. Калибровка чинится после обучения и AUC не портит.
Часть I · Метрики: чем меряют кандидатогенерацию и ранжирование
Micro или macro: по чему усреднять метрику?
По запросам (micro) — измеряется средний запрос, и число почти целиком определяется активными пользователями, у которых запросов в десятки раз больше. По пользователям (macro) — измеряется средний пользователь, все голоса равны.
Практическое следствие: релиз, который убивает онбординг, по micro выглядит нейтральным — новичков по головам может быть половина, а запросов от них проценты. Macro при этом просядет заметно.
Ответ «смотрю обе и отдельно режу по когортам» — правильный: расхождение между micro и macro само по себе диагноз.
Потому что в данных почти наверняка есть признаки, проксирующие популярность: счётчики показов, покупок, средний рейтинг. При случайном сплите они посчитаны по всему датасету, включая тест, и уже содержат будущие клики. Модель этим пользуется, метрики выглядят прекрасно, а в проде такого признака не существует.
Правильно — глобальная отсечка по времени, причём все агрегаты и счётчики тоже считаются только по данным до неё, иначе временной сплит перестаёт быть временным.
Leave-one-out выглядит временным, но отсечка у каждого пользователя своя, поэтому агрегаты снова текут. Это известная методологическая проблема академических сравнений.
Что делать с холодными пользователями и айтемами в тестовой выборке?
Принять решение явно, потому что от него сильно зависит число. Выбросить холодных — завысить качество: в проде они есть всегда. Оставить и считать промахом — честно, но смешивает качество модели с покрытием холодного старта.
Рабочий вариант — считать две метрики отдельно, на тёплых и на холодных, плюс долю холодных как самостоятельное число. Иначе улучшение модели и ухудшение покрытия сливаются в одно движение метрики.
Зачем нужен бейзлайн популярности, если он заведомо хуже?
Он не соперник, а измерительный прибор: показывает, сколько метрики набирается вообще без персонализации. Ответ обычно неприятно высокий.
Дальше он читается как диагностика. Модель обходит популярность на проценты — вопрос о её сложности становится содержательным. Не обходит вовсе — почти наверняка ошибка в протоколе. Обходит подозрительно сильно — ищите утечку: слишком хороший результат на первом прогоне это симптом, а не успех.
Сколько трафика нужно, чтобы поймать прирост в 1%?
Зависит от базовой конверсии, и зависимость квадратичная по абсолютной разнице. При базовом CTR 5% и стандартных 5% значимости с мощностью 80% — около 3 млн наблюдений на группу. При CTR 1% — 15.6 млн. При CTR 20% — 630 тысяч.
Ключевое соотношение: вдвое меньший эффект требует вчетверо больше данных (проверено численно: 753 тыс против 3.0 млн, отношение 3.98). Поэтому предложение «померить +0.1%» обычно означает «нам не хватит трафика», и это надо посчитать до запуска, а не после.
Почему нельзя подглядывать в результаты A/B по ходу?
Уровень значимости 5% относится к одной проверке. Каждая следующая — ещё один шанс случайно пересечь порог.
На A/A-тесте, где истинный эффект строго нулевой: одна проверка в конце даёт честные 5.0% ложных срабатываний, ежедневная проверка в течение недели — 16.5%, в течение двух недель — 21.5%. То есть каждый пятый «успешный» эксперимент пустой.
Что делать: фиксировать горизонт заранее; либо брать последовательный критерий или alpha spending, где подглядывание разрешено по построению. И отдельно — остановка «как только покрасилось» систематически завышает оценку эффекта, потому что останавливаются на случайном максимуме.
Когда рандомизация по пользователям даёт смещённую оценку?
Когда нарушается изоляция групп. Три типичных случая.
Двусторонние рынки: тестовая группа выкупает ограниченный ресурс — курьеров, товар, — и контролю достаётся меньше. Лечится рандомизацией по городам, дарксторам или временным слотам, switchback-тестами.
Социальные графы: активность теста видят друзья из контроля. Лечится кластерной рандомизацией по компонентам графа.
Общие модели: система переобучается на логах обеих групп, и тестовая политика подмешивает данные в обучение — контроль перестаёт быть контролем. Самый незаметный случай, потому что инфраструктура при этом работает штатно.
Часть II · Коллаборативная фильтрация и меры похожести
Чем отличаются Жаккар, косинус и PMI?
Силой нормировки. Все три имеют вид \(|A\cap B| / (|A||B|)^{\alpha}\): косинус \(\alpha = 0.5\) (корень), Жаккар примерно линейно, PMI \(\alpha = 1\) (полная нормировка).
Практически: косинус мягче к популярному, чем Жаккар — в примере с блокбастером 10⁶ и нишевым 10³ при пересечении 900 косинус даёт 0.028, Жаккар 0.0009, разница в тридцать раз.
PMI отвечает на другой вопрос: не «насколько велико пересечение», а «во сколько раз оно больше ожидаемого при независимости». Поэтому два блокбастера с пересечением 210 тысяч при ожидаемых 200 тысячах получают у PMI почти ноль, а два нишевых с пересечением 600 при ожидаемых шести — 4.61.
Часть II · Коллаборативная фильтрация и меры похожести
Почему PMI нельзя использовать в чистом виде?
Он взрывается на редких парах. Пара из пятнадцати наблюдений получает PMI 5.93 — больше, чем честная пара на шестистах наблюдениях с её 4.61. В пределе: два айтема, которых посмотрел ровно один и тот же человек, дают \(\ln 10^6 = 13.8\), то есть чистый шум занимает первое место.
Причина в том, что PMI меряет отношение к ожиданию, а когда ожидание близко к нулю, любое единичное совпадение даёт гигантское отношение.
Лечение — NPMI (нормировка на \(-\log P(A,B)\), которая наказывает редкость) плюс обязательно порог по числу совстречаемостей и сжатие для малых счётчиков. Одного NPMI мало: пара из пятнадцати наблюдений всё ещё получает 0.534.
Часть II · Коллаборативная фильтрация и меры похожести
Правда ли, что NPMI очищает похожесть от популярности?
Нет, и это распространённая неточность. Проверяется прямо: возьмите три пары с одинаковым превышением ожидания — популярную, среднюю и нишевую. NPMI даст им разные значения, причём популярная получит больше, потому что у неё меньше знаменатель \(-\log P(A,B)\).
То есть NPMI вносит собственное предпочтение, обратное тому, что делает PMI. На практике это скорее полезно — шум давится, — но формулировка «очищено от популярности» неверна.
Часть II · Коллаборативная фильтрация и меры похожести
Чем выученная item-item матрица лучше посчитанной?
Формула скоринга у них одна и та же: пройти по истории и сложить веса. Различие в том, откуда веса. Посчитанная мера смотрит на пару айтемов изолированно, регрессия видит все предикторы вместе.
Это чинит три вещи. Популярность перестаёт притворяться связью: айтем, который лежит в каждой корзине, получает почти нулевой вес, потому что ничего не добавляет сверх остальных. Дубликаты перестают считаться дважды: регрессия делит вес между коллинеарными предикторами. И появляются отрицательные веса — субституты, которые формула похожести выразить не может в принципе.
Формально \(B_{ij} = -P_{ij}/P_{jj}\) — это коэффициенты частной регрессии, то есть переход от маргинальной зависимости к условной.
Часть II · Коллаборативная фильтрация и меры похожести
EASE или SLIM: в чём разница и что выбрать?
EASE — это SLIM, с которого сняли \(L_1\) и ограничение \(B \ge 0\). Взамен появляется решение в замкнутой форме: одно обращение матрицы вместо координатного спуска по столбцам.
По качеству разрыв объясняется целиком ограничением на знак: EASE с занулёнными отрицательными весами падает ровно до уровня SLIM (0.402 против 0.401 на ML-20M). Около 60% весов в EASE отрицательны — модель тратит большую часть ёмкости на «чего не надо».
Выбор по ресурсам: EASE стоит \(O(|I|^3)\) времени и \(O(|I|^2)\) памяти, зато не зависит от числа пользователей. Для десятков тысяч айтемов — берите EASE. Если каталог таков, что плотная матрица не помещается, — SLIM с его разреженностью.
Чем матричная факторизация отличается от коллаборативной фильтрации?
Это не альтернативы: факторизация — метод внутри коллаборативной фильтрации. CF делится на подходы по соседям (kNN, item-item — считаем похожести прямо по матрице) и обучаемые, куда входит факторизация, а также EASE и SLIM. Причём «обучаемая» и «факторизованная» — разные свойства: EASE обучается, но матрицу не раскладывает.
Содержательное отличие в механизме обобщения. Соседи обобщают через прямые совстречаемости, факторизация — через сжатие в \(d\) координат. Поэтому она находит связи там, где совпадений почти нет: в примере айтемы, встретившиеся вместе 12 раз из 20 000, получают у соседей косинус 0.04, а у факторизации ранга 2 — 0.98.
И обратное: у взаимоисключающих айтемов соседи дают 0 («нет данных»), факторизация −1 («противоположны»). Второе утверждение соседям недоступно.
Настоящее сингулярное разложение требует полной матрицы. У нас матрица разреженная, и заполнять пропуски нулями нельзя: пропуск означает «не показывали», а не «не понравилось».
То, что стали называть SVD после Netflix Prize, — градиентный спуск Саймона Функа по наблюдённым ячейкам, то есть минимизация ошибки только на известных парах с регуляризацией. Название прижилось, метод другой.
Задача не выпукла по \((P,Q)\) вместе, но выпукла по каждой матрице отдельно. Зафиксировав \(Q\), для каждого пользователя получаем обычную гребневую регрессию с решением \(p_u = (Q_u^\top Q_u + \lambda I)^{-1} Q_u^\top r_u\).
Отсюда три преимущества: каждый \(p_u\) считается независимо и задача идеально параллелится; нет learning rate, который надо подбирать; сходимость за десяток итераций. Система при этом размера \(d \times d\) — с ранг, а не с каталог.
SGD выигрывает, когда матрица очень разреженная и нужна онлайн-донастройка, а также когда лосс не квадратичный — для BPR или логистического ALS не выведешь.
Ранг — число координат у каждого вектора, и это содержательная гипотеза: «любой вкус есть смесь \(d\) архетипов». У произведения матриц ширины \(d\) ранг не превышает \(d\), поэтому выбор \(d\) ограничивает то, что модель вообще способна выразить.
Цена измерима: параметров \((|U| + |I|)\cdot d\). На матрице 30×40 с 378 обучающими наблюдениями ранг 6 даёт 420 параметров — больше, чем данных, и модель подгонит обучающую выборку, ничего не выучив.
Подбирается по отложенной выборке. Обучающая ошибка монотонно падает с ростом ранга и не подсказывает ничего; минимум есть только у отложенной.
Зачем нужна регуляризация в ALS — назовите обе роли.
Первая, обычная: сжатие к нулю. Пользователю с одной оценкой выгодно выдать огромный вектор, идеально в неё попадающий; штраф за длину делает это невыгодным, и модель говорит «не знаю» вместо того, чтобы уверенно выдумывать.
Вторая, о которой забывают: обусловленность. Матрица \(Q_u^\top Q_u\) для пользователя с двумя-тремя взаимодействиями вырождена — её ранг меньше \(d\), обратной не существует. Добавка \(\lambda I\) делает её обратимой всегда, поэтому при \(\lambda = 0\) решение для редкого пользователя просто не посчитается.
Разделяет цель и уверенность. Целевая переменная бинарная — «есть предпочтение», а вес \(c_{ui} = 1 + \alpha r_{ui}\) показывает, насколько мы в этом уверены: слушал сто раз или один. Учимся на всех парах, а не только на наблюдённых, и ненаблюдённые входят с весом 1 — это мягкие негативы, которые одно наблюдение перебивает.
Сумма по всем парам не взрывается из-за разложения \(Q^\top C_u Q = Q^\top Q + Q^\top(C_u - I)Q\): первое слагаемое не зависит от пользователя и считается раз на итерацию, второе — сумма только по наблюдённым, которых мало.
И \(\alpha\) — настоящий гиперпараметр, не единица: разумные значения порядка десятков.
Как выдать рекомендации пользователю, которого не было в обучении?
Fold-in. Векторы айтемов уже обучены и меняются медленно, поэтому достаточно решить ровно тот шаг ALS, который отвечает за пользователей: \(p_{\text{new}} = (Q_s^\top Q_s + \lambda I)^{-1} Q_s^\top r_s\), где \(Q_s\) — векторы того, с чем человек взаимодействовал.
Это система \(d \times d\) — при ранге 8 микросекунды, никакого переобучения. На синтетике с каталогом 300 и 25 взаимодействиями топ-25 совпадает с настоящими интересами в 17 случаях из 25.
Важно, что обратное не работает: холодный старт по айтемам так не лечится. У нового айтема нет вектора, и взять его неоткуда — нужны контентные признаки, то есть другая архитектура.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Что такое folding и почему это фатально для кандидатогенератора?
Наложение непересекающихся групп пользователей и айтемов в пространстве эмбеддингов. Причина формальная: если непоказанные пары в лосс не входят, между пользователями одной группы и айтемами другой нет ни одного ограничения, лосс распадается на независимые подзадачи, и взаимное расположение групп не определено ничем. Один из равноправных оптимумов — тот, где группы лежат друг на друге.
Это не переобучение, а вырожденность задачи. Ранкеру она не мешает: он применяется к кандидатам из той же системы, что породила логи. Кандген идёт в ANN по всему каталогу — и получает айтемы чужой группы с высоким скором.
Диагностика: разница между HitRate на исходном пуле и по всему каталогу. В симуляции 0.392 против 0.196 при 50.8% чужих в топ-5; у модели с негативами из каталога 0.362 против 0.362 и 0.0%.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Почему модель с folding внутри своей группы работает лучше?
Потому что она не сломана. Она потратила всю ёмкость ровно на ту задачу, которую ставил лосс — различать айтемы внутри показанного пула, — и делает это хорошо: 0.392 против 0.362 у правильно обученной.
Проблема в том, что вне обучающего носителя она не определена. Расширение множества кандидатов до каталога стоит ей половины качества (0.392 → 0.196), тогда как правильная модель не теряет ничего.
Отсюда же следует, почему folding не виден офлайн: метрики считаются на отложенной выборке, а она собрана той же политикой и тоже лежит внутри блоков.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Решают ли in-batch негативы проблему folding?
Да, и это главная причина, по которой ретривал учат батч-софтмаксом. Батч набирается из глобального потока, поэтому в нём есть представители всех групп, и лосс создаёт межгрупповые ограничения.
Но спасает не «in-batch», а глобальное перемешивание. Если данные шардированы по стране или сегменту, на регион учится своя модель, либо примеры сгруппированы по сессии — соседи по батчу снова свои, и folding возвращается: в симуляции 43.8% чужих в топ-5.
И на уровне отдельных айтемов in-batch не чинит ничего: айтем без фидбека никогда не станет ни позитивом, ни негативом, потому что в батч попадают только позитивы. Отсюда Mixed Negative Sampling с равномерной добавкой из корпуса.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Почему вклад негатива в градиент зависит от того, как модель его оценила?
В градиенте полного софтмакса стоит сумма по каталогу с множителем \(P(d \mid u)\) — вероятностью, которую выдаёт сама модель. Айтем с низким скором входит почти с нулевым весом: про него модель уже всё поняла.
Численно при позитиве со скором 3.0: случайный негатив со скором −2 даёт вклад ×1, популярный со скором +0.5 — ×11, хард-негатив со скором +2.8 — ×67.
Отсюда фраза «полный софтмакс сам майнит хард-негативы»: их никто не ищет, веса расставляются автоматически. Это свойство и теряется при замене каталога выборкой, и выбором источника негативов его пытаются вернуть.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Выведите LogQ-коррекцию и объясните, что она делает.
В градиенте полного софтмакса нужно матожидание \(\nabla s\) по целевому распределению \(P(\cdot \mid u)\). Сэмплируем из предложенного \(Q\) и применяем importance sampling: вес становится \(\omega_d = e^{s}/Q(d) = e^{\,s - \log Q(d)}\).
То есть деление на \(Q\) внутри экспоненты — это вычитание \(\log Q\) из логита: \(s^c = s - \log Q(d)\).
Смысл: чем чаще айтем попадает в выборку, тем больше вычитаем и тем меньше его вес в знаменателе — он наказывается слабее ровно на тот множитель, на который перепредставлен. Без коррекции модель сходится к \(\log p - \log Q\) и систематически занижает популярное — смещение работает против хитов, а не в их пользу.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Как оценить Q на потоке, где нет фиксированного словаря?
Оценивать не частоту, а среднее число шагов между попаданиями айтема в батч: \(p = 1/\delta\). Держатся два хеш-массива — шаг последнего попадания и скользящее среднее интервала, обновляемое как \(B \leftarrow (1-\alpha)B + \alpha(t - A)\).
Схема работает без фиксированного словаря, адаптируется к дрейфу и живёт на parameter servers при распределённом обучении.
Коллизии занижают интервал и потому завышают частоту, поэтому берут несколько независимых пар массивов и максимум по ним — каждая отдельная оценка занижена, максимум ближе к истине.
Часть II · Двухбашенные модели: folding, негативы и LogQ
Зачем нормировать эмбеддинги и зачем при этом температура?
Нормировка убирает влияние нормы, которая в обучении растёт с популярностью: без неё скалярное произведение систематически предпочитает популярное независимо от релевантности.
Но после нормировки логиты зажаты в \([-1,1]\), и софтмакс от них почти равномерен. На логитах \((1,-1,-1,-1,-1)\) правильный ответ получает всего 64.9% массы — градиент размазывается между правильным и явно неправильными.
Температура \(s/\tau\) возвращает резкость: при \(\tau = 0.5\) вес правильного ответа 93.2%. Её фиксируют, задают расписанием или делают обучаемой.
Часть II · Кодирование объектов: обучаемые эмбеддинги против контентных
Почему ID-эмбеддинги называют самой мощной моделью объекта?
У каждого объекта \(k\) собственных, ни с чем не связанных параметров, поэтому достижимо любое расположение векторов; при \(k = |\mathcal{I}|\) воспроизводится любая матрица похожестей.
Отсюда следствие: что бы ни выдал контентный энкодер, таблица способна выдать то же самое — выход энкодера это одно конкретное присвоение, а таблица принимает любое. Обратное неверно: энкодер обязан дать одинаковые векторы объектам с одинаковыми признаками.
То есть inductive bias у них нулевой. Это верхняя граница выразительности и одновременно причина всех проблем: модель нечем удержать от запоминания шума.
Часть II · Кодирование объектов: обучаемые эмбеддинги против контентных
Почему свободные эмбеддинги плохо работают на хвосте?
Потому что строка таблицы обучается только на примерах с этим айтемом, и связать её со строками похожих нечем. Арифметика: при каталоге 10 млн, миллиарде взаимодействий и размерности 256 у 97.7% айтемов свободных параметров больше, чем наблюдений о них.
Для такой строки задача недоопределена. Модель может только запомнить единичные наблюдения, а отличить редкий, но настоящий сигнал от шума она не в состоянии — снаружи они выглядят одинаково.
Часть II · Кодирование объектов: обучаемые эмбеддинги против контентных
Что такое one-epoch phenomenon и почему нельзя просто уменьшить разреженность?
Качество растёт в течение первой эпохи и резко падает в начале второй — обрыв, а не плавная деградация, в отличие от компьютерного зрения. Практическое следствие: лучший результат часто даёт обучение ровно в одну эпоху, и промышленные пайплайны так и устроены.
Гипотеза о причине: распределение \((\mathrm{EMB}(x), y)\) различается для виденных и новых примеров, потому что у виденных эмбеддинг подстроился под метку. На второй эпохе MLP адаптируется именно к распределению виденных и теряет применимость к новым. То есть переобучение идёт на артефакт собственной таблицы.
Разреженность снизить можно — отфильтровать редкие значения или захешировать в меньше бакетов, — и эффект действительно ослабевает. Но это часто ухудшает итоговое качество: редкие признаки одновременно источник переобучения и источник сигнала, ради которого их заводили.
Часть II · Кодирование объектов: обучаемые эмбеддинги против контентных
Почему контентное кодирование вытягивает хвост?
Потому что хвост признаков короче и толще хвоста айтемов, и это проверяемо. На каталоге 100 тысяч с шестью признаками из словаря в 5 тысяч: наблюдений меньше десяти у 74.2% айтемов и у 0.0% признаков. Медиана — 4.5 у айтема против 1275 у признака, разница в 283 раза.
Механизм в переиспользовании: редкий айтем собран из частых признаков. У самого редкого айтема каталога ожидается две единицы наблюдений, а его признаки видели от 479 до 2.5 миллиона раз. Про сам айтем не известно ничего, про то, из чего он состоит, — всё.
Плюс экономия: 5 тысяч строк вместо 100 тысяч, и у 3.7% из них наблюдений меньше размерности вместо 98.3%.
Часть II · Кодирование объектов: обучаемые эмбеддинги против контентных
Чем платят за переход на контентные признаки?
Потолком на голове каталога: два товара с одинаковым описанием получат одинаковые векторы, даже если данных достаточно, чтобы знать — конвертят они по-разному. ID-эмбеддинги это подхватывают, энкодер по признакам нет.
Плюс качество признаков становится критичным — плохая токенизация ограничивает сильнее любой архитектуры, — и вектор надо вычислять, а не доставать, что дороже на инференсе.
Поэтому в проде гибрид: контент даёт обобщение и холодный старт, ID добирает то, чего в признаках нет. Вопрос не «что вместо чего», а в какой пропорции.
Часть II · Кодирование объектов: обучаемые эмбеддинги против контентных
Что даёт hashing trick, кроме экономии памяти?
Главное — представимость нового объекта немедленно. Не надо ждать, пока id попадёт в словарь и словарь пересоберут: хеш считается сразу. Для быстро обновляющегося каталога это не оптимизация, а условие работоспособности.
Цена — коллизии: несколько id делят строку и становятся неразличимы. Несколько независимых хеш-функций делают полную неразличимость произведением вероятностей, а память растёт линейно — обмен выгодный.
И важная деталь: коллизии бьют по хвосту. У популярного айтема достаточно данных, чтобы перетянуть общую строку на себя; у редкого — нет.
Арифметика не сходится. При каталоге 10 млн и размерности 256 это 5.1 млрд операций на запрос — порядка 100 мс при 50 GFLOPS, тогда как бюджет ретривала обычно 10 мс. Разрыв в порядок величины константами не закрывается.
Плюс память: 10.2 ГБ во float32 только под сами векторы.
Оговорка: если каталог влезает в память GPU, полный перебор может оказаться быстрее приближённого поиска на CPU, и тогда он предпочтительнее — нет потери полноты, нет перестроения индекса, и скор не обязан быть скалярным произведением.
У косинуса норма вектора сокращается, у скалярного произведения — нет. Поэтому MIPS систематически предпочитает айтемы с большой нормой, а норма при обучении растёт с популярностью. Получается popularity bias, заложенный в саму геометрию, а не в данные.
Практическое следствие: индекс надо строить под ту меру, которую оптимизирует модель. Либо нормировать эмбеддинги и работать с косинусом, либо брать индекс с inner-product метрикой, либо свести MIPS к обычному поиску соседей добавлением дополнительной координаты.
Многослойный граф — по сути skip-list, обобщённый на метрическое пространство. Уровень точки сэмплируется из геометрического распределения, поэтому на каждом следующем слое точек примерно в M раз меньше. Поиск начинается сверху, где точек мало, жадно спускается по рёбрам и проваливается на слой ниже. Ожидаемая сложность \(O(\log N)\).
efSearch — размер очереди на нижнем слое, то есть ручка «полнота против задержки» на уже построенном индексе. Меняется в рантайме без перестроения, поэтому же служит механизмом контролируемой деградации под нагрузкой.
Слабые места: память под рёбра (до M на вершину на каждом слое) и деградация при удалениях, которые делаются пометкой и требуют периодического перестроения.
Что даёт product quantization и чем за это платят?
Вектор режется на m подвекторов, каждый кодируется номером центроида из своего кодбука. При 256 измерениях, 32 подвекторах по 8 бит вектор занимает 32 байта вместо 1024 — сжатие в 32 раза, каталог на 10 млн ужимается с 10.2 ГБ до 0.32 ГБ.
Плюс расстояния считаются быстрее: для запроса один раз считаются расстояния до всех центроидов, дальше расстояние до любого вектора — m сложений по таблице.
Платят точностью: средняя ошибка на координату около 0.43 при разбросе 1.0. Поэтому используют двухфазный поиск — по сжатому индексу отобрать сотни кандидатов, затем пересчитать точные расстояния только для них.
Иерархический код, получаемый остаточным квантованием: первый кодбук приближает вектор, второй — остаток, третий — остаток от остатка. Айтем становится кортежем из нескольких токенов.
Арифметика: 6 уровней по 256 дают \(2.8 \cdot 10^{14}\) комбинаций при длине кода 6 байт против 1024 у float32-вектора — сжатие в 171 раз. При каталоге 10 млн трёх уровней уже хватает, чтобы адресовать айтем; остальные уточняют.
Но главное не сжатие, а иерархия: близкие по смыслу айтемы получают общий префикс. Новый айтем наследует префикс своей области, и модель, никогда его не видевшая, уже знает о нём главное. Это тот же механизм, что у контентных признаков — редкий объект собран из частых кусков.
Если айтем — последовательность токенов, рекомендация становится генерацией: модель предсказывает код токен за токеном, как языковая модель предсказывает слова. Индекс при этом исчезает вовсе: нет ANN, нет перестроения, нет размена полноты на задержку.
Заодно снимается ограничение двух башен — скор перестаёт быть скалярным произведением, потому что авторегрессия видит уже сгенерированные токены.
Риск специфический: модель может сгенерировать код, которому не соответствует ни один существующий айтем. Лечится ограниченным декодированием по префиксному дереву валидных кодов. И оценивать подход стоит сдержанно — до масштаба десятков миллионов айтемов его довели немногие.
Чем pointwise, pairwise и listwise отличаются по смыслу?
Множеством, на котором считается одно слагаемое лосса, и отсюда всё остальное.
Pointwise моделирует саму релевантность (BCE на 0/1) — даёт калиброванные вероятности, но не знает ни про порядок, ни про структуру слейта: ошибка на позиции 1 и на позиции 50 штрафуется одинаково.
Pairwise моделирует наблюдаемый порядок — согласован с AUC, потому что AUC и есть доля правильно упорядоченных пар, но калибровку теряет по построению.
Listwise моделирует структуру слейта — ближе всего к NDCG, дороже в обучении и калиброван ещё хуже.
Два свойства, которые надо назвать сразу. Лосс зависит только от разности скоров — значит инвариантен к сдвигу и калибровки не даёт. И градиент по скору позитива равен \(-\sigma(-\Delta)\): перепутанная на 3 пара весит 0.95, уже разведённая на 3 — 0.047, то есть в 20 раз меньше. Майнинг трудных пар встроен в форму лосса.
Почему AUC и NDCG не видят калибровки, и когда это больно?
Обе метрики зависят только от порядка, а любое монотонное преобразование скора порядок сохраняет. Значит калиброванная модель и её же скор, пропущенный через монотонную функцию, неотличимы по AUC и NDCG.
Больно это становится ровно тогда, когда скор участвует в арифметике. Пример: пять товаров, ранжирование по скору у обеих моделей одинаковое, а ранжирование по ожидаемой выручке \(p \cdot \text{цена}\) — разное; наверх встаёт товар с ожидаемой выручкой 30 вместо 90, потеря 67% на верхней позиции. Логлосс при этом ухудшается на 27% — он разницу видит.
Отсюда практика: \(pCTR \cdot bid\) в аукционе, \(P(\text{buy})\cdot\text{price}\), пороги для бизнес-правил — везде нужен pointwise-выход, даже если итоговый порядок делает другой лосс.
Что такое LambdaRank и почему его считают listwise-лоссом?
Это pairwise-лосс, в котором каждая пара взвешена изменением метрики от её перестановки: \(\mathcal{L} = \sum_{(i,j)} \Delta\mathrm{NDCG}_{ij}\log(1+\exp(-s_{ij}(f_i-f_j)))\). Второй множитель — тот же BPR, всё своеобразие в первом.
Зачем: NDCG кусочно-постоянна и недифференцируема, напрямую её не оптимизировать, но градиенты можно взвесить вкладом пары в метрику. Разброс огромный — перестановка на позициях (1,2) даёт ΔNDCG 0.3691, на (9,10) — 0.0120, то есть в 31 раз меньше.
А listwise он потому, что ΔNDCG считается по всему слейту: нужны позиции всех остальных документов и IDCG запроса. Формально попарный лосс несёт информацию о структуре всего списка.
Как устроен YetiRank и чем он отличается от LambdaRank?
Сам лосс — попарный, тот же BPR. Всё в весах \(w_{ij} = N_{ij}\cdot c(l_i,l_j)\).
\(N_{ij}\): скоры многократно зашумляют логистическим шумом и пересортировывают; вес получают только пары, оказавшиеся соседними, с прибавкой \(1/R\) по позиции соседства. Отсюда — позиционный дисконт встроен, матрица весов разрежена (не более 990 пар из 4950 при 100 документах и 10 возмущениях, то есть линейно по n вместо квадратично), а веса пересчитываются на каждой итерации бустинга.
\(c(l_i,l_j)\): вероятность, что \(i\) действительно лучше \(j\), с учётом матрицы ошибок разметки. Пара «3 против 2» получает срезанный вес, пара «4 против 0» — почти единичный.
Отличие от LambdaRank: тот взвешивает пару тем, насколько изменится метрика при перестановке, исходя из того, что текущий порядок верен. YetiRank взвешивает тем, насколько вероятно, что пара вообще окажется рядом и высоко, — то есть явно моделирует собственную неуверенность.
Три случая. Первый — бинарные метки: множитель \(c(l_i,l_j)\) вырождается, остаётся просто взвешенный BPR. Второй — метки из логов, а не от асессоров: клик не «перепутан», он смещён позицией и показом, и матрица ошибок разметки такой шум не описывает; нужны поправки на смещения. Третий — когда нужна калибровка: как всякий попарный лосс, он зависит только от разностей скоров.
Второй случай — самая частая ошибка применения: метод берут в рекомендации по аналогии с поиском, где разметка действительно асессорская.
Чем listwise-софтмакс отличается от sampled softmax в кандидатогенерации?
Формула одна и та же. Отличие в том, откуда взялось множество в знаменателе, и оно принципиальное.
В кандидатогенерации знаменатель — сэмплированные негативы: мы сами задали распределение, значит знаем его и можем поправить смещение LogQ-коррекцией. В ранжировании знаменатель — показанный слейт, то есть результат логирующей политики; её распределение нам не подконтрольно.
Плюс listwise-выход не калиброван особенно сильно: добавьте в слейт из четырёх айтемов один сильный, и «вероятность» первого упадёт с 0.4551 до 0.2034 — на 55%, хотя сам айтем не изменился.
Какое ограничение есть у всех трёх семейств сразу?
Все они оптимизируют прокси при фиксированной политике показа. Айтемов, которые политика никогда не показывала, нет ни в поточечной сумме, ни в парах, ни в знаменателе софтмакса — никакой лосс о них модели не расскажет.
Это и есть петля обратной связи: модель учится на том, что показала предыдущая модель. Выбором лосса круг не разорвать — надо влиять на саму политику показа, то есть заниматься exploration.
Почему в рекомендациях не хватает градиентного бустинга?
Три конкретные причины. Высокая кардинальность: one-hot на миллион значений не построить, а target encoding теряет семантику — два айтема с одинаковым средним CTR и разной аудиторией склеиваются в одно число, и это стоит 50% кликов в простом примере с двумя сегментами.
Неструктурированные данные: тексты, картинки, история — по ним можно нагенерировать признаки, но работает хуже.
И главное: эмбеддинги на входе в дерево работают плохо принципиально. Координаты по отдельности ничего не значат, а сплит смотрит на одну координату; чтобы различить 4 уровня по каждому из 16 измерений, нужно 4.29 млрд листьев, тогда как линейный слой обходится 16 умножениями.
Плюс два практических аргумента: много сигналов сразу и дрейф — бустинг надо перестраивать целиком, сеть можно дообучать.
Когда не выполняется ни один пункт чек-листа: много данных, важные высококардинальные признаки, неструктурированные данные, много сигналов, потребность в быстром дообучении. Если ничего из этого нет, CatBoost на агрегатах будет и дешевле, и лучше.
Главный минус сетей — порог входа: GPU, распределённое обучение, инференс в рантайме. Это реальная стоимость, и делать вид, что её нет, на собеседовании не стоит.
Представим категорию one-hot вектором \(e_i \in \{0,1\}^n\) и умножим на матрицу \(W \in \mathbb{R}^{n \times d}\) без свободного члена. Результат \(e_i W\) — это \(i\)-я строка \(W\). Значит \(W\) и есть таблица эмбеддингов, а лукап — просто оптимизация умножения на разреженный вектор.
Полезное следствие: при \(d = 1\) модель выучивает средний CTR значения, то есть target encoding — частный случай эмбеддинга размерности один. Всё, что даёт эмбеддинг сверх этого, — измерения, в которых помещается «кому именно нравится».
Он должен зависеть от кардинальности и от информативности признака; одинаковый размер для всех неоптимален.
Информационная оценка: \(d\cdot s = \log_2 n\) бит. Эвристика Google: \(d = 6\sqrt[4]{n}\). Разрыв между ними огромный — при \(n=10^7\) это 23 бита против d = 337 — и он содержательный: почти вся ёмкость идёт под семантику, а не под идентификацию.
Буквально эвристику не берут: при \(n=10^9\) она даёт 1067 и 4.3 терабайта на признак. В проде типичные размерности 32–128, а честный путь — подбирать размерности прямо в обучении.
Batched lookup: объединить таблицы всех признаков в одну матрицу и делать единый лукап со сдвигом — иначе получается много мелких операций на GPU с накладными расходами на запуск ядра.
Sparse gradients: батч на 4096 сэмплов трогает не больше 4096 строк из 10 млн, то есть 0.041% таблицы; остальные градиенты — нули, их не надо ни обновлять, ни пересылать между картами. Важная тонкость: для таких параметров стоит увеличивать learning rate, потому что обновляются они сильно реже плотных весов.
Rowwise optimizer: хранить статистики Adam не на параметр, а на строку. Таблица 10 млн × 256 занимает 10.2 ГБ, с моментами Adam — 30.7 ГБ, а с построчными статистиками 10.32 ГБ, то есть на 0.8% больше таблицы.
Плюс шардирование, выгрузка на CPU и хеш-трюк, когда таблицы не помещаются на GPU.
Зачем преобразовывать вещественные признаки и как?
Затем, что сети чувствительны к масштабу (большие значения доминируют в градиенте), ломаются на выбросах и не умеют обрабатывать пропуски. У деревьев ни одной из этих проблем нет — это плата за переход к сетям.
Пять преобразований: логарифм против скошенности; сигмоида, зажимающая выбросы (с нормализацией до неё, иначе затухают градиенты); функция распределения, приводящая признак к равномерному; периодические функции для времени; квантизация по квантилям.
У квантизации принципиальный недостаток — теряется отношение порядка и между бинами, и внутри бина. Его чинит кусочно-линейное кодирование.
Потому что в сыром виде 23 и 0 — максимально далёкая пара из возможных, хотя это соседние часы. Модель вынуждена тратить ёмкость на то, чтобы выучить склейку на границе суток.
Пара синус-косинус одной частоты кладёт время на окружность: расстояние между 23 и 0 становится 0.2611 — ровно таким же, как между 0 и 1, и минимальным из возможных. Самой далёкой парой оказывается 23 и 12, то есть противоположные точки суток, как и должно быть.
Несколько частот дают несколько окружностей разного масштаба — час, день недели, сезон одновременно. Механизм тот же, что у позиционных эмбеддингов в трансформере.
Кусочно-линейное кодирование: вектор длины T, где слева от текущего бина единицы, справа нули, и ровно одна компонента дробная — доля пройденного бина. Линейный слой поверх такого вектора даёт непрерывную кусочно-линейную функцию.
Отличие от one-hot по бинам: внутри бина выход не константа, а линейно едет. На приближении \(x^2\) при 16 бинах MSE отличается в 857 раз. И важнее разрыва то, как он растёт: у кусочно-постоянного кодирования ошибка падает как \(T^{-2}\), у кусочно-линейного — как \(T^{-4}\).
Почему дискретизованный semantic ID работает лучше плотного контентного вектора?
Потому что он разделяет два режима, которые в плотном векторе смешаны. Плотный контентный вектор — «мягкий» признак: по нему модель обобщает, но не может запомнить специфику конкретного объекта. Дискретный иерархический код даёт и то и другое: общий префикс отвечает за обобщение на похожие объекты, полный код — за меморизацию конкретного.
Это ровно тот случай, когда дискретизация не теряет информацию, а структурирует её. Обратная сторона того же аргумента — хеш-трюк: его идентификаторы семантики не несут, поэтому для нового объекта получается неизвестная модели комбинация, полный упор в меморизацию и ноль обобщения.
Расскажите цепочку моделей взаимодействий признаков.
Шесть шагов, каждый чинит поломку предыдущего. Линейная модель — взаимодействий нет вовсе. Кросс-признаки второй степени — есть, но \(O(n^2)\) параметров и никакого обобщения на невиданные комбинации. FM — факторизуем матрицу весов, память \(O(nd)\) и обобщение по транзитивности, но только вторая степень. Ранние сети (Wide & Deep, DeepFM, DLRM) — ставим MLP сверху, и выясняется, что пары он сам не находит. DCN-v2 — вписываем умножение прямо в слой. Трансформеры над признаками — пытаемся выбирать важные пары динамически.
Практический итог: по умолчанию берут DCN-v2. Всё левее — история, всё правее — исследовательский фронт.
Две вещи, и вторая серьёзнее. Размер: параметров \(O(n^2)\) по суммарной кардинальности; при n = 3 млн это 4.5 триллиона весов, 18 ТБ во float32 — и это ради взаимодействий всего второй степени.
Обобщение: вес невиданной комбинации не обучен вовсе, а невиданных подавляющее большинство. При 30 полях по 100 тысяч значений и миллиарде сэмплов даже в идеальном случае встретится не более 10% комбинаций, а с учётом степенного распределения — на порядки меньше. Это проблема представления, а не масштаба: железом её не решить.
Факторизацией матрицы весов: \(w_{ij} \approx \langle v_i, v_j \rangle\). Каждому признаку — вектор, вес пары — скалярное произведение.
Память падает с \(O(n^2)\) до \(O(nd)\): при n = 3 млн и d = 32 это 384 МБ вместо 18 ТБ, в 47 тысяч раз меньше.
Обобщение возникает по транзитивности: чтобы оценить вес пары (i, j), достаточно, чтобы \(v_i\) обучился на других парах с i, а \(v_j\) — на парах с j. Счётно это выглядит так: при 1000 признаков всего 499 500 пар, а параметров FM при d = 16 всего 16 000, то есть 3.2% — информации от такой доли пар хватает, чтобы восстановить остальные.
Почему нельзя просто поставить MLP и надеяться, что он выучит произведения?
Потому что универсальность аппроксимации — утверждение о существовании весов, а не о том, что градиентный спуск найдёт их на конечных данных.
Произведение для MLP неудобно: он строит его кусочно-линейными сплайнами, на что уходит много нейронов и много данных. Rendle и соавторы показали прямо, что выученная MLP-похожесть проигрывает обычному скалярному произведению.
А в рекомендациях ещё хуже: признаки разреженные, конкретная комбинация встречается редко, и учить произведение просто не на чем. Отсюда вывод — вписывать умножение в архитектуру, а не надеяться на него.
\(x_{l+1} = x_0 \odot (W_l x_l + b_l) + x_l\). Три части. \(W_l x_l\) — линейная комбинация всей конкатенации, поэтому внутри можно получить скалярное произведение любых векторов признаков независимо от их размерностей: ограничение DeepFM и DLRM на единую размерность снято. Покоординатное умножение на \(x_0\) — вписанное в архитектуру произведение, каждый слой поднимает степень на единицу. Плюс остаточная связь.
L слоёв дают комбинации степени до L+1, но больше 2–3 слоёв прироста не дают: число мономов растёт быстрее, чем данные их покрывают — на четвёртой степени при сотне признаков это уже 3.9 млн комбинаций.
Слой тяжёлый: \(W_l\) размера d × d по всей конкатенации. Отсюда низкоранговый вариант \(W_l \approx U_l V_l^\top\) — при d = 1024 и r = 64 в 8 раз дешевле — и дальше смесь низкоранговых разложений.
Stacked обычно работает лучше parallel: сначала cross network, потом MLP над его выходом, с сужением широкого вектора перед MLP.
Важная и неочевидная деталь: cross network не даёт прироста поверх абстрактных векторных представлений — его надо применять именно над конкатенацией векторов признаков. Если подать уже перемешанное представление, например выход трансформера, умножать оказывается нечего: координаты больше не соответствуют признакам.
И разделение труда: cross явно моделирует низкие порядки, MLP неявно — высокие. Они дополняют друг друга, а не конкурируют.
Почему обычный трансформер плохо подходит для признаков?
Потому что он обрабатывает все токены одинаково. Для текста это верно — токены однородны. В рекомендациях признаки гетерогенны по природе: возраст, ID артиста, цена, эмбеддинг из другой модели. Единое преобразование навязывает однородность, которой нет.
Отсюда Hiformer с раздельными Q, K, V и своим FFN на признак — проблема настоящая, но слои требуют очень много памяти. AutoInt, применяющий обычное внимание, работает хуже DCN-v2.
Почему добавление слоёв в MLP ухудшает качество и что с этим делают?
Градиенты затухают: множители якобианов перемножаются. Если каждый слой умножает градиент на 0.8, то при 32 слоях остаётся 7.9e-04 — падение в 1262 раза, нижние слои практически не обучаются.
ResNet: \(y = F(x) + x\). Якобиан блока это \(I + F'\), и в произведении есть слагаемое из одних единиц — путь целиком по skip-связям, поэтому снизу градиент ограничен единицей. Заодно лишний слой можно занулить: при \(F \equiv 0\) он тождественный. Сверху произведение растёт, поэтому skip всегда идёт с нормализацией. Минус — сумма требует совпадения размерностей, слои тяжёлые.
DenseNet: вместо суммы конкатенация, размерности совпадать не обязаны, F может быть дешёвым сужающим слоем. При входе 512 и четырёх блоках с ростом 64 это 1.56e+05 параметров против 1.05e+06 у ResNet — в 6.7 раза меньше. Плата — растущая ширина.
Часть III · Мультизадачность, дебиасинг и дистилляция
Зачем ранжирующей модели несколько задач и в чём беда shared bottom?
Затем, что сигналов всегда несколько: вовлечённость (клики, время), удовлетворённость (лайки, оценки), долгосрочные метрики. Отдельная модель на каждый — в разы больше ресурсов и сложнее поддержка.
Shared bottom даёт регуляризацию, перенос знания и экономию. Беда возникает на конфликтующих задачах: градиенты голов тянут общее представление в разные стороны. В простой геометрической модели, где направления задач расходятся на угол α, каждая получает cos(α/2): при 90° это 0.71, потеря 29%, при 180° — ноль.
Лечение «в лоб» — расширить ствол, чтобы места хватило обоим направлениям. Аккуратнее — MMoE.
Часть III · Мультизадачность, дебиасинг и дистилляция
Что такое MMoE и чем он отличается от MoE?
MoE: \(y = \sum_i g_i(x) f_i(x)\), где \(f_i\) — эксперты, \(g\) — гейт; ключевая идея — условные вычисления, на объекте работает часть экспертов.
MMoE — multi-gate: у каждой задачи свой гейт, то есть свои веса экспертов. Если задачи не связаны, под каждую выучивается своё подмножество экспертов, и конфликт не возникает.
Важное замечание для честного сравнения: гейты — это порядка полутора процентов параметров, всё остальное просто ёмкость экспертов. Поэтому MMoE надо сравнивать не с узким shared bottom, а с расширенным до той же ёмкости — иначе непонятно, что дало прирост.
Часть III · Мультизадачность, дебиасинг и дистилляция
Что такое ESMM и какую проблему он решает?
Проблема — несоответствие срезов обучения и применения. Учить P(покупка | показ) напрямую мешает разреженность: при CTR 5% и CVR 3% дисбаланс 1 к 666, плюс сигнал слишком сложный. Учить P(покупка | клик) проще, но такая голова обучается на 5% данных (только клики) и применяется ко всем показам — в 20 раз большему числу объектов.
Почему это ломает ранжирование: pCVR — вероятность при условии клика. Нишевый товар с pCTR 0.01 и pCVR 0.40 по условной вероятности чемпион, а по ожидаемой покупке 0.004 против 0.0072 у популярного — потеря 44% на верхней позиции.
ESMM: моделируем цепочку показ → клик → покупка, P(покупка|показ) = P(клик|показ)·P(покупка|клик), две головы. И ключевой приём — вторую голову учат на лосс по P(покупка|показ), то есть на всём пространстве показов; градиент доходит до неё через произведение.
Часть III · Мультизадачность, дебиасинг и дистилляция
Почему CTR выше на первых позициях и что с этим делать?
По двум разным причинам, и их надо различать: пользователи чаще кликают наверх, потому что видят раньше и больше доверяют (это смещение), и наверху действительно стоят более релевантные айтемы (это работа модели). Смешать их — значит вычесть вместе со смещением настоящую релевантность.
Конкретно смещение переворачивает порядок: айтем с релевантностью 0.12 на позиции 5 даёт наблюдаемый CTR 0.039, а айтем с релевантностью 0.10 на позиции 1 — 0.100. Наивная модель уверенно ставит выше худший.
Четыре лечения: учить только на первой позиции (при слейте 20 остаётся 5% данных), позиция как признак с подстановкой pos=1 на инференсе (модель может спрятать релевантность в позиционный признак), IPW (нужен случайный трафик, а разброс весов в 8 раз раздувает дисперсию) и bias-башня — продовый стандарт.
Часть III · Мультизадачность, дебиасинг и дистилляция
Как устроена bias-башня?
Отдельная маленькая башня, на вход которой идёт вся контекстная информация, полезная для моделирования смещения, — не только позиция, но и, например, устройство: на телефоне видно меньше позиций, чем на десктопе.
При обучении выходы складываются в логите: основная модель плюс башня. Обязательный элемент — дропаут выхода башни, иначе она объяснит позицией слишком многое, перетянет сигнал релевантности и основная модель недоучится. На инференсе башню выкидывают, остаётся чистый скор.
Красота идеи в том, что смещение моделируется явно и отделяемо: мы не вычитаем его постфактум, а даём отдельный канал, куда его удобно списать, и потом отрезаем этот канал.
Часть III · Мультизадачность, дебиасинг и дистилляция
Зачем учиться на предсказания учителя, если есть настоящие метки?
Две причины. Первая: метка бинарна, предсказание — нет. «Клика не было» одинаково выглядит для объекта с вероятностью 0.40 и с вероятностью 0.02, а учитель эти случаи различает. Это dark knowledge — передаётся не ответ, а форма распределения.
Вторая: учитель — сглаженная версия данных. При истинной вероятности клика 0.05 и десяти показах стандартная ошибка оценки 0.069, то есть 138% от самой величины: метка почти ничего не говорит о конкретном объекте. Учитель усреднил шум по всей выборке, и ученик получает менее шумную цель — а шум и есть главный источник переобучения на хвосте.
Мотивация всего приёма — размен качества на задержку: учитель считается офлайн, ученик влезает в бюджет ранкера.
Часть III · Мультизадачность, дебиасинг и дистилляция
Почему у ученика делают две головы?
Потому что у учителя есть свои смещения: он обучался на логах той же системы и унаследовал её позиционное смещение, перекос к популярному и петлю обратной связи. Дистиллируя его напрямую, мы дистиллируем и это.
Решение: одна голова учится на целевые метки, вторая — на предсказания учителя, а для ранжирования используется первая. Дистилляционная голова работает как вспомогательная задача: она заставляет общее представление вобрать знание учителя, но не диктует порядок. Знание проходит через общий ствол, смещения остаются в чужой голове.
Второй мотив — калибровка: предсказания учителя её ломают, а голова на настоящих метках сохраняет. Для рекламы, где скор умножается на ставку, это решает.
Часть III · Мультизадачность, дебиасинг и дистилляция
Где дистилляция ломается в проде?
Четыре места. Учитель дрейфует при переобучении, и цель ученика меняется скачком — причём мониторинг этого не покажет, обе модели по отдельности выглядят нормально. Учителя надо считать по всему обучающему потоку — дистилляция переносит стоимость из инференса в обучение, а не убирает её. Ученик наследует систематические ошибки учителя и на этом подмножестве никогда его не превзойдёт, хотя на настоящих метках мог бы. И замкнутый круг версий: учитель обучен на логах предыдущего ученика, через несколько итераций система дистиллирует саму себя.
Чем target attention отличается от усреднения истории?
Тем, что веса зависят от кандидата: \(v_U(A) = \sum_j w_j e_j\), где \(w_j = g(e_j, v_A)\). У пользователя больше нет одного вектора — у него вектор на каждого кандидата.
Зачем: усреднение растворяет редкое, но решающее событие. В истории из 12 событий единственный музыкальный трек получает при усреднении вес 0.083, а внимание даёт ему 0.835 — в десять раз больше. Скор меняет знак с −0.56 на +0.83.
Когда история однородна, разница минимальна — именно поэтому проблему долго не замечали.
Почему в DIN не нормализуют веса внимания софтмаксом?
Потому что сумма весов интерпретируется как интенсивность интереса пользователя к кандидату. При softmax-нормализации она всегда равна единице, и эта информация теряется: пользователь с десятью релевантными событиями в истории и пользователь с одним дадут свёртку одинаковой «массы».
То есть нормализация выравнивает ровно то, что хотелось бы различать.
Задаёт, насколько внимание сосредоточено. Веса — софтмакс от скалярных произведений, делённых на τ, и на обоих концах шкалы конструкция вырождается.
При τ = 2 энтропия весов 2.43 при максимуме ln(12) = 2.48 — внимание превращается в обычное усреднение, ради ухода от которого всё и затевалось. При τ = 0.05 энтропия ноль, вес одного события равен единице — жёсткий выбор одного элемента, остальная история выброшена.
Причина та же, что в двухбашенной модели: скалярные произведения нормированных векторов зажаты в [−1; 1], и без деления софтмакс над ними почти равномерен.
Расскажите про SASRec, BERT4Rec и чем закончилась их история.
SASRec — перенос из NLP: айтемы как слова, пользователи как предложения, задача next item prediction, трансформер-декодер с каузальной маской. BERT4Rec — двунаправленное внимание и cloze task вместо каузального предсказания; заодно там учились на полный софтмакс. BERT4Rec показывал результаты лучше.
Развязка: SASRec с правильным лоссом обгоняет BERT4Rec. Исходное преимущество объяснялось не двунаправленным вниманием, а функцией потерь — в ванильном SASRec это была бинарная кросс-энтропия с одним равномерным негативом.
Насколько это плохо: при каталоге в миллион вероятность, что случайный негатив окажется одним из ста ближайших к позитиву, равна 0.0001. Чтобы увидеть трудный негатив хотя бы в половине случаев, нужно почти семь тысяч сэмплов. Модель почти никогда не видит того, что должна научиться различать, и архитектура тут ни при чём.
Урок: сравнивая две работы, первым делом сверяйте лосс и схему негативов.
Из-за нагрузки. При раннем связывании вектор пользователя зависит от кандидата, поэтому трансформер по истории надо прогонять для каждого кандидата отдельно.
Счёт: история 100 событий, d = 256, два слоя, 500 кандидатов. Один прогон — 6.3e+07 операций, то есть 1.25 мс при 50 GFLOPS. Пятьсот прогонов — 627 мс, при бюджете ранжирования 20 мс это промах в 31 раз.
Решение — позднее связывание: двухбашенная архитектура, где трансформер по истории и контексту считается один раз, айтем кодируется своей башней, а скор получается скалярным произведением. Тот же компромисс «выразительность против стоимости», что в кандидатогенерации, только теперь на этапе ранжирования.
Два аргумента. Информационный: показ выбран предыдущей моделью, а клик — самим человеком; показы говорят о системе, а не о пользователе. Инженерный: при CTR 5% показов в двадцать раз больше, а внимание квадратично по длине, значит стоимость растёт в 400 раз.
Получается, что показы и дороже, и менее информативны — решение принимается легко.
Прямо её в модель на запросе не поставить: внимание квадратично, и история в 8000 событий дороже сотни в 1113 раз, причём квадратичный член там уже 94% стоимости.
Два подхода. Event picking — выбирать среди глубокой истории по содержательному принципу, а не брать последние n. И офлайн-контур — считать вектор глубокой истории заранее, раз в сутки.
У офлайн-контура плюсы: дёшево, проще внедрять, за те же ресурсы влезает более тяжёлая модель, история до двух тысяч событий. Минусы принципиальны: нет свежих событий и нет контекста. Поэтому он не заменяет контур реального времени, а дополняет его — вектор глубокой истории подаётся на вход быстрому трансформеру.
Напрямую его никто не сообщает. Мерят по возврату в выдачу: если следующее событие пользователя произошло через t секунд, примерно столько он и провёл на документе; отсутствие возврата трактуется отдельно. На этом строится таргет «глубокий клик» — пребывание дольше N секунд.
Это хороший пример общего свойства рекомендательных данных: интересующая нас величина не наблюдается, и вместо неё конструируется прокси со своими смещениями.
Зачем нужен отдельный слой переранжирования, если ранкер уже всё отсортировал?
Потому что ранкер оценивает айтемы поштучно, а пользователь видит список целиком, и айтемы в нём взаимодействуют. Два айтема с вероятностью клика 0.20 дают 0.36, если независимы, и всего 0.20, если это дубликаты. А оригинал плюс менее релевантный айтем из другой темы (0.12) дают 0.296 — слейт на 48% лучше.
То есть «выбрать лучшие k айтемов» и «собрать лучший список из k айтемов» — разные задачи, и вторая не сводится к первой сортировкой.
Целью, а не механизмом. При exploration мы отклоняемся от жадного топа, чтобы получить данные о том, чего не показывали, — платим текущим качеством за будущее качество модели. При работе с разнообразием мы отклоняемся по продуктовым соображениям: список из десяти почти одинаковых товаров плох здесь и сейчас, независимо от того, какие данные соберём.
Механизм похож, но бюджеты и метрики успеха разные.
На трёх уровнях. Внутри выдачи — intra-list diversity (средняя непохожесть пар) и энтропия категорий. По пользователю за период — доля новых для него категорий, серендипность. По системе — coverage и Джини по показам.
Энтропия удобнее простого счёта категорий, потому что штрафует и за перекос: слейт «8 + 1 + 1» и слейт «4 + 3 + 3» содержат по три категории, но энтропия 0.639 против 1.089 — почти вдвое. А «8 + 1 + 1» это выдача из одной темы с двумя случайными вкраплениями.
Жадный отбор со штрафом за похожесть на уже выбранное: на каждом шаге берём \(\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 нельзя посчитать, не выбрав предыдущие, — а это ровно то, чего не выдерживает бюджет ранжирования. Плюс модель наследует разнообразие из логов, собранных предыдущей политикой.
Что такое петля обратной связи и как из неё выбраться?
Модель обучается на логах, собранных предыдущей версией той же модели. Всё, что она не показывала, в данных отсутствует, и хороший айтем без показов останется неизвестным: чтобы узнать CTR, надо показать; чтобы показать, надо знать CTR.
Выход — менять логирующую политику. Два дешёвых способа: случайная квота (ε-greedy) и софтмакс-сэмплинг с температурой. Ими обычно забирают первые 95% выгоды. Дальше — бандитный слой с оценкой неопределённости.
Важная оговорка про софтмакс-сэмплинг: он переранжирует только то, что уже отобрала воронка, а воронка отбирала жадно. Настоящее исследование требует механизма и на retrieval.
Из неравенства Хёфдинга: для выборки на [0,1] с истинным средним μ и выборочным \(\hat\mu\) верно \(\mathbb{P}(\mu \ge \hat\mu + u) \le e^{-2nu^2}\). Приравниваем правую часть к δ и разрешаем относительно u: \(U = \sqrt{-\ln\delta / 2n}\).
Дальше выбираем δ = 1/k^c — с ростом времени требуем всё более надёжную границу — и получаем \(a_k = \arg\max [Q_k(a) + c\sqrt{\log k / n_k(a)}]\).
Читается как «оценка средней награды плюс ширина доверительного интервала». Бонус убывает как \(1/\sqrt{n}\): при 1 показе он 3.03, при 1000 уже 0.096. Поэтому отдельной ручки исследования не нужно — алгоритм сам сокращает его по мере накопления данных.
UCB детерминирован и оптимистичен: берёт верхнюю границу интервала. Thompson байесовский: сэмплирует параметры из апостериора и действует жадно относительно сэмпла. Для бинарной награды это Beta-Bernoulli — априор Beta сопряжён с бернуллиевским, обновление тривиально (успех → α+1, неуспех → β+1), три строки кода и никаких гиперпараметров кроме априора.
По regret оба сублинейны, в отличие от ε-greedy: у того при удвоении отрезка прирост удваивается (73.9 → 142.5), потому что фиксированная доля случайного трафика тратится вечно.
Главное отличие на практике — не в награде, а в измеримости, см. следующий вопрос.
Почему в проде чаще берут Thompson, если UCB даёт больше награды?
Из-за склонностей. UCB детерминирован: при данном состоянии порядок один и тот же, значит π(i|u) равна нулю или единице, и IPS, SNIPS, doubly robust превращаются в деление на ноль. Офлайн оценить новую модель по таким логам нельзя.
Thompson задаёт распределение над перестановками: склонности положительны и оцениваются повторным сэмплированием при тех же μ и σ. Один слой закрывает обе задачи — и исследует, и делает данные пригодными для оценки.
Оговорка: положительных склонностей мало, важен ещё их разброс. Если у 1% наблюдений вес 100, эффективный размер выборки падает с 1000 до 39 — оценка формально несмещённая, но бесполезная. Отсюда обрезка весов и требование, чтобы политика не уходила далеко от логирующей.
Прямой перенос не работает: в классической постановке ручек единицы и каждую дёргают тысячи раз, а тут миллионы айтемов и ноль показов в хвосте. Варианты — айтем, источник кандидатов, политика целиком, группа айтемов.
Почти всегда выбирают айтем, и это возможно потому, что счётчик показов — не единственный источник неопределённости. Обученная модель умеет отвечать «я не знаю» про айтем с незнакомыми признаками и пустой историей. Неопределённость берётся из модели, а не из счётчика.
Отсюда правильная формулировка: бандит — надстройка над ранкером, а не замена. Ранкер отвечает за среднее, бандит — за то, что делать с разбросом вокруг него.
Четыре инженерные причины. Цена ошибки ограничена: слой переставляет сотню уже отфильтрованных кандидатов, худшее — показать девятого вместо второго; на retrieval цена не ограничена ничем. Здесь есть полный вектор признаков, а σ нужна про пару (пользователь, айтем) в контексте. Кандидатов мало: ансамбль из пяти моделей на сотне — посильно, на десяти тысячах — нет. И это точка, где решение и так принимается.
Ограничение: слой исследует только то, что отобрала воронка. Если retrieval жадный, бандит исследует внутри уже смещённого пула.
Три источника: ансамбль моделей (выборочная дисперсия предсказаний), MC-dropout (дропаут на инференсе, несколько проходов головы) и neural linear (байесовская линейная регрессия поверх последнего слоя, даёт честную \(\sqrt{x^\top A^{-1}x}\)). Первые два почти ничего не стоят по разработке.
Главное — не перепутать эпистемическую неопределённость («модель не знает», убывает от данных) с алеаторической («событие случайно», не убывает никогда). Соблазн взять \(\sigma = \sqrt{p(1-p)}\) велик — формула под рукой. Но это чистая алеаторика: при p = 0.3 она равна 0.4583 и при одном показе, и при десяти тысячах. Такой бонус максимален при p = 0.5 и просто тянет наверх середнячков.
Проверка: постройте σ как функцию числа показов. Нет зависимости — измеряете не то. И не собирайте ансамбль из моделей с общей таблицей эмбеддингов: предсказания совпадут, σ схлопнется.
Помнить, что зависимость — горб, а не лестница. В симуляции α = 3 даёт 0.909 от идеальной выдачи против 0.834 у жадного (плюс 9% перестановкой того же пула), а α = 5 уже 0.891: мы начинаем поднимать наверх и тех, про кого всё понятно.
И отдельно — про малые α. При α = 0.5 самородок заглядывает в топ в 7 каталогах из 8, а закрепляется в топ-1 только в 2: он получает несколько показов, ловит неудачную серию, оценка проседает, а бонус \(\propto 1/\sqrt{n}\) уже подсох. Малый бонус хуже, чем никакой — тратит показы, но не доводит дело до конца.
Когда хватает случайной квоты и софтмакс-сэмплинга — а хватает их, чтобы забрать первые 95% выгоды. Слой окупается при трёх условиях сразу: каталог быстро обновляется, есть честная оценка неопределённости, нужны склонности для офлайн-оценки.
Плюс есть детерминированная альтернатива: жёстко поднимать ровно один пост малоизвестного автора на фиксированную позицию. Так сделано в открытом коде ленты X. Предсказуемо, объяснимо, десять строк — и если задача только в том, чтобы новый автор не умер в безвестности, этого достаточно.
И методическая ловушка: выигрыш exploration проявляется в качестве будущих моделей, а меряют его недельным A/B по сегодняшним метрикам. На этом горизонте он почти всегда выглядит проигрышем.
Потому что разные данные имеют разную свежесть и разную стоимость подсчёта. «Пользователь любит молочку» — агрегат за 90 дней, за 50 мс на запросе его не посчитать. «Молоко уже в корзине» видно только в реальном времени. Всё считать в рантайме дорого, всё заранее — неактуально или невозможно.
Отсюда лямбда: batch пересчитывает агрегаты с нуля раз в сутки, speed инкрементально обновляет по событиям, serving хранит две зеркальные части, а отдельный сервис профиля их мержит с учётом того, что batch мог уже учесть часть событий из дельт.
Главная боль — одна логика агрегации живёт в двух местах: баг чинить дважды, деплой дважды, часто разные языки и разные команды.
Расхождение способа подсчёта признака между обучением и инференсом. Канонический пример: офлайн считает возраст клиента от первого заказа, онлайн — от регистрации. Модель обучается на одном, инферит на другом.
Масштаб: при разрыве в 14 дней доля клиентов старше порога сдвигается на 4.6 п.п. в середине распределения, где сосредоточена основная масса. И это одна фича из сотен.
Feature skew — вторая по частоте причина расхождения офлайн- и онлайн-результатов после неправильно выбранной метрики. Лечится общей библиотекой вычисления признаков, которую зовут и офлайн, и онлайн.
Логирование даёт ровно то, что видела модель, гарантирует одинаковое распределение и позволяет воспроизвести предикт. Цена — место и задержка: при 10⁸ запросов, 100 кандидатах и 500 признаках это 20 ТБ в сутки и 7.3 ПБ в год, а новый признак становится доступен для обучения только через окно обучения после начала записи.
Реконструкция снимает и то и другое, но точно восстановить состояние почти невозможно, не все признаки восстановимы (это ограничивает дизайн), и легко получить утечку из будущего.
Промежуточное решение — логировать только показанных: десятикратная экономия, но теряется информация о непоказанных кандидатах, нужная для офлайн-оценки политик.
Бэкенд: идентификаторы запроса, пользователя и айтема, серверное время, позиция, скор модели. Фронтенд: обязательно идентификатор запроса, с которого пришёл айтем, плюс идентификаторы, плюс время — серверное или по попаданию в хранилище, клиентским часам верить нельзя.
Мержим по идентификатору запроса. При прочих равных предпочитаем бэкендные логи: клиентские теряются, и потерянный клик не становится пропуском — он становится нулём, то есть ложным негативом. При 10% потерь наблюдаемый CTR смещается вниз ровно на 10%, и в данных не остаётся следа.
Склейка идентификаторов одного человека: user_id при логине, device_id разных устройств, cookie_id. Без неё история размазана.
Конкретно: у пользователя 60 событий, порог содержательной истории — 20. На пяти идентификаторах получается по 12, и ни один порога не достигает — человек выглядит новичком по каждому своему устройству. Это фейковый холодный старт: история есть, но раздроблена.
Особенно критично там, где пользователи осознанно скрывают поведение — приватный режим, отсутствие логина. Работать с идентификаторами надо на уровне общего слоя признаков, иначе склейка будет реализована по-разному в трёх местах, и мы вернулись к skew.
Доля запросов, для которых признак доступен. Следят по двум причинам.
Первая — арифметика накопления: при 50 признаках с покрытием 0.95 у каждого полный вектор есть лишь у 7.7% запросов. Работа с пропусками — основной режим, а не краевой случай.
Вторая — расхождение обучения и прода: обучались при покрытии 0.87, в проде 0.60, доля запросов в невиданном режиме выросла с 13% до 40%. Поведение модели там непредсказуемо.
И резкое падение покрытия почти всегда означает поломку выше по потоку — это самый дешёвый сигнал тревоги из существующих.
Covariate — изменилось распределение X (новая когорта, кампания, сезон); лечится переобучением и починкой источника. Label — изменилось распределение Y (кликрейт вырос, потому что пришла лояльная аудитория); надо разобраться в причине. Concept — изменилась сама связь X → Y; самое трудное, переобучение помогает, только если контент успевает.
Различать важно, потому что label drift легко принять за успех модели.
Детекция: для признаков — PSI по децилям обучающего распределения, пороги 0.10 и 0.25. Порядок величин полезно помнить: сдвиг в полсигмы даёт PSI 0.24, а в четверть сигмы — только 0.06. Для предсказаний — распределение скоров на инференсе. Для таргетов — посуточный CTR по сегментам.
Как устроено переобучение и чем рискует онлайн-дообучение?
Три варианта конвейера: руками (для старта), по расписанию (достаточно для большинства задач), по триггеру — падение метрики, детекция дрейфа, накопление N примеров (для новостей и подобного).
Нейросетям тут удобнее бустинга: бустинг обучается с нуля каждый раз, а сеть можно дообучить поверх старых весов, заморозив нижние слои.
Риски онлайн-дообучения: забывание паттернов, важных для редких сегментов; усиление петли обратной связи, потому что модель быстрее замыкается на собственных рекомендациях; и быстрое впитывание аномалий вроде бот-атаки. Обычно комбинируют: регулярное полное переобучение как якорь плюс быстрое дообучение сверху.
Почему кандидатогенерацию и признаки выносят в отдельные сервисы?
Главная причина — разный паттерн нагрузки. Признаки и модели упираются в CPU и GPU, кандидатогенерация — в память. В одном процессе сервису нужно одновременно и то и другое, и он плохо масштабируется горизонтально: добавляя реплики ради CPU, вы дублируете весь индекс.
Счёт: индекс 100 ГБ, вычислительный слой 4 ГБ. При восьми репликах монолит держит 832 ГБ, разнесённая система — 232 ГБ, в 3.6 раза меньше; разница это шесть лишних копий индекса.
Остальные причины: разделение ответственности, устранение единой точки отказа, гибкость экспериментов, граница между командами. Плата — возможное дублирование части данных в памяти, и при двух репликах выигрыша нет вовсе.
Первая: иначе тратим дорогое время инференса на айтемы, про которые заранее знаем, что не покажем. При бюджете в 500 кандидатов и 30% отсева поздний фильтр оставляет 350 полезных вместо 500 — это на 43% меньше за те же деньги.
Вторая: выдача может оказаться короткой, что роняет бизнес-метрики. Чтобы гарантированно показать 20 позиций при 30% отсева, надо поскорить 29, иначе дозапрос и рост времени ответа.
Идеальный вариант — устроить кандидатогенерацию так, чтобы кандидаты рождались уже отфильтрованными: не тратить работу впустую вообще. Дороже в реализации.
Тем, что весь трафик одномоментно уходит вниз по стеку, к которому он не готов. И зависимость контринтуитивна: чем лучше работал кеш, тем сильнее удар. При hit rate 0.95 нижний слой рассчитан на 5% трафика и получает 100% — скачок в 20 раз; при 0.99 — в 100 раз.
То есть система с отличным hit rate выглядит здоровее, а на самом деле уязвимее. Отсюда два правила: бережно выбирать ключи кеширования и завести на это отдельные тесты. Инвалидация всего кеша — это не «медленнее ответим», это отказ.
Как устроен фильтр Блума и зачем он в рекомендациях?
k хеш-функций и битовый массив длины m. При вставке ставим единицы по хешам, при проверке смотрим те же позиции. Вероятность ложного срабатывания \((1 - e^{-kn/m})^k\), оптимум \(k^* = (m/n)\ln 2\), и при нём занята ровно половина бит.
Кривая по k U-образная: при m = 8000 и n = 1000 один хеш даёт 11.75%, шесть — 2.16%, двенадцать — снова 4.83%. Мало хешей — мало различающей силы, много — фильтр забивается.
В рекомендациях — для пагинации. Система нестабильна между запросами, поэтому наивная пагинация даёт дубли; вместо этого между фронтом и бэком гоняют сериализованный фильтр с уже показанным. Годится именно Блум, потому что ложноотрицательных не бывает: ошибка возможна только в сторону «спрячем лишнее», а дубль не покажем никогда.
Чтобы удерживать долю категории в выдаче, когда спрос меняется. Жёсткая квота вставляет айтемы на фиксированные позиции и ломает осмысленность выдачи; регулятор вместо этого подкручивает бонус к скору, и выдача остаётся отсортированной по смыслу.
Ключевое — зачем нужна интегральная часть. Пропорциональный регулятор даёт воздействие, пропорциональное ошибке, значит при нулевой ошибке нет и буста: система живёт с постоянным недобором. В симуляции при I = 0 доля стабилизируется на 12% вместо 30% — промах 17.7 п.п., и усиление P до 2 сокращает его только до 12.7, добавляя колебания. Убирает статическую ошибку исключительно I.
Обратная сторона — слишком большой P раскачивает контур: при P = 3 разгон доходит до 58% против цели 30%.
Деградировать по ступеням, а не отдавать пятисотую. Блендеру плохо — отключаем поздние переранжирования и берём меньше кандидатов. Плохо кандидатогенерации — экстренный ANN или популярность прямо на блендере. Плохо сервису признаков — отвечаем скорами ANN. Совсем плохо — тыква.
Почему это не редкий случай: цепочка из пяти сервисов с доступностью 0.999 каждый даёт 0.995, то есть 43.7 часа простоя в год. Причём разнесение на сервисы эту арифметику ухудшает — за гибкость масштабирования платят числом мест, где можно сломаться.
Переходы между ступенями надо продумать заранее, а включать их автоматически — предохранителями и привязкой к утилизации.
Намеренно неинтеллектуальный, но точно работающий режим: предрассчитанные один раз популярные товары, популярное по сегменту, редакторские подборки, простая бизнес-логика без персонализации.
Правила: чем тупее тыква, тем лучше; на случай жёсткого инцидента лучше зафиксировать в конфиге огромный список айтемов, чтобы точно пролетели все фильтрации; полезно иметь несколько последовательных тыкв; и делать их снаружи от основного сервиса — если он лежит, он не отдаст даже тыкву.
Главное: если вы давно не тестировали свою тыкву, значит, у вас её скорее всего уже нет.
Помимо обычного (задержки по перцентилям, ошибки, таймауты, попадание в кеш, лаги очередей) — специфичное: доли источников кандидатов, долю свежих айтемов, долю фоллбек-ответов по каждому фоллбеку, покрытие категориями, возраст модели, индекса и признаков, покрытие признаков и дрейфы.
И главная абстракция — мониторить воронку целиком: сколько кандидатов подняли, сколько выжило после фильтрации, сколько поскорили, сколько попало в ответ, сколько заняла каждая ступень. Такой мониторинг сразу локализует проблему, тогда как метрика финальной выдачи только говорит «стало хуже».
Спроектируйте рекомендательную систему для X. С чего начнёте?
С пяти шагов в таком порядке: целеполагание (бизнес-метрика → офлайн-метрика → таргет модели), ограничения (каталог, пользователи, SLA, фильтрации, интент, смещения), техническая архитектура, ML-архитектура, сетап эксперимента. Потом критика получившегося и второй проход.
Обоснование — закон Голла: работающая сложная система всегда развилась из простой работающей, а спроектированная с нуля не работает и не чинится заплатками. Причём простое решение нужно не как запасной вариант, а как инструмент понимания: критиковать мы будем на основе того, что доосознали, пока его строили.
Первое, что я спрошу до всякой архитектуры: какая бизнес-метрика, насколько редкое целевое событие, каков размер каталога и бюджет ответа.
Потому что грубую ошибку на этом этапе не вытащит никакая модель ниже: если оптимизируется не то, качество реализации не имеет значения.
И это ровно тот этап, который хочется пропустить: многим не терпится начать делать, и обычно это полезное свойство — потому оно и опасно именно здесь. Частичная компенсация — строить сначала простое решение, чтобы быстро дойти до работающего и вернуться к цели уже с пониманием задачи.
Посчитать. Если ранжирующая модель тратит порядка 4 мкс на кандидата, а бюджет 300 мс, то полный перебор влезает примерно до 75 тысяч кандидатов. Каталог в 3000 айтемов перебирается за 12 мс — воронка не нужна вовсе. Каталог в 500 тысяч требует отбора в 7 раз, сотни миллионов — в тысячи раз.
И важно, что отказ от воронки при маленьком каталоге — не упрощение, а правильное решение: любая воронка теряет кандидатов, а потолок качества задаёт её первая стадия.
Бизнесу нужны подписки. Почему нельзя мерить подписки?
Потому что событие слишком редкое. Число наблюдений для фиксированного относительного эффекта пропорционально (1−p)/p, поэтому поймать тот же прирост на конверсии 0.5% вместо 40% стоит в 133 раза больше трафика: 1.25 млн на группу против 9.4 тысяч.
Нужна прокси-метрика — чувствительная в коротком A/B и коррелирующая с долгосрочной целью. Строят её так: выбрать базовые метрики с правдоподобным долгосрочным эффектом (просмотр сериалов, дискавери, частота использования), собрать из них комбинации-кандидаты, ретроспективно отобрать лучшие и обязательно проверить на золотом наборе прошлых экспериментов с известным исходом. Последний шаг и есть настоящая валидация.
Сеткой 3 + 3. Выписать акторов — обычно пользователь, айтем, контекст — и придумать признаки для каждого по отдельности и для каждой пары.
Например: пользователь — частота покупок и средний чек; айтем — продажи и CTR за X дней; контекст — день недели и эмбеддинг поверхности; пользователь × айтем — скалярное произведение башен и сколько раз он брал эту категорию; пользователь × контекст — сколько он обычно покупает в этот час; айтем × контекст — CTR товара в это время.
Сетка почти всегда покрывает существенное, и её удобно проговаривать вслух как структуру ответа.
Что такое каннибализация поверхностей и как с ней быть?
Ситуация, когда две поверхности показывают одному пользователю пересекающийся ассортимент, и их приросты не складываются. Если каждая изолированно даёт +10, то при половинном пересечении вместе они дадут +15, а не +20; при полном — +10, то есть вторая не добавляет ничего.
Классическая ловушка отчётности: команды честно показывают приросты своих поверхностей, а общий оборот не растёт. Лечится метриками на уровне пользователя, а не поверхности, и аплифт-постановкой — покупка на этой поверхности относительно покупки вообще. Причём для аплифтов нужен полноценный общий слой признаков.
Показать топ популярного, дать выбрать — и дальше ключевой приём: удалить из выборки всех пользователей, которым выбранное тоже нравилось, и перестроить топ.
Зачем: без удаления топ почти не меняется, и следующий вопрос не приносит новой информации. В симуляции на пяти вкусовых группах наивная схема пять раз подряд спрашивает про одну и ту же группу — покрыта 1 из 5; с удалением покрываются все 5.
Общая формулировка: мы выбираем следующий вопрос так, чтобы он максимально делил оставшуюся неопределённость, а не так, чтобы он был самым популярным.
Система перестаёт быть двусторонней и становится трёхсторонней. У авторов свои ожидания: они рассчитывают на показы, и их отсутствие вызывает жалобы. Инструменты — item exploration внутри модели, PID-контроллеры для стабилизации объёма показов, явные квоты.
И следствие, о котором забывают: авторам платят за показы, значит появляется фрод — боты и накрутка CTR. Таргет и целевая метрика должны быть антифрод-aware, иначе система будет добросовестно оптимизировать накрутку.
Порядок величин при бюджете 300 мс: сеть и разбор 20, профиль 30, кандидатогенерация 40, фильтрация 15, признаки 60, ранжирование 80, переранжирование 25, ответ 20 — итого 290, запас 10 мс.
Два вывода. Первый: запас уйдёт на хвост распределения, поэтому бюджет держат по p99, а не по среднему. Второй: признаки и ранжирование съедают 47% бюджета, и ускорение искать надо сначала там, а не в кандидатогенерации, куда смотрят инстинктивно.
Дополнение · Открытый код ленты «For You»: что это и как читать
Зачем разделять ранжирование и фильтрацию по видимости? Почему не занизить скор?
Три причины. Разная цена ошибки: ранжирование ошибается на единицы процентов вовлечения, видимость — на репутации и требованиях закона; сводить их в один скор значит смешивать несопоставимые величины. Разная скорость изменений: правила видимости меняются за часы, модель переобучается по расписанию. Проверяемость: правило можно прочитать и оспорить, понижение скора моделью — нельзя.
Дополнительно: понижение скора не гарантирует, что пост не покажут. Если конкурентов мало, пост с заниженным скором всё равно окажется в выдаче. Жёсткое требование «не показывать» реализуется только жёстким фильтром.
Дополнение · Открытый код ленты «For You»: что это и как читать
Почему фильтрация по видимости стоит после ранжирования, а не до?
Потому что она дорогая и вызывается по паре «пост и зритель». До ранжирования кандидатов тысячи, после отбора — сотня. Спросить сотню дешевле в десять раз.
Общее правило то же, что в главе «Порядок фильтрации»: дешёвые фильтры, работающие по данным, которые уже есть в памяти (возраст поста, блокировки, уже показанное), ставят до ранжирования; дорогие, требующие похода в другой сервис, — после. При этом надо помнить, что фильтрация после отбора уменьшает выдачу, и на это закладывают запас: отбирают с избытком.
Дополнение · Открытый код ленты «For You»: что это и как читать
Что даёт предсказание отдельных действий вместо одного скора релевантности?
Развязывает модель и продуктовую политику. Веса действий меняются конфигом и раскатываются экспериментом, без переобучения. Плюс редкие негативные сигналы вроде репорта получают собственную голову и не растворяются в общем лоссе.
Цена: головы конкурируют за общее тело; редкие задачи склонны к переобучению; веса нельзя вывести теоретически, их подбирают A/B-тестами, и это долго. Подробнее — глава «Мультизадачность» и страница x05.
Дополнение · Открытый код ленты «For You»: что это и как читать
Почему кандидатам запрещено смотреть друг на друга в трансформере?
Чтобы скор поста не зависел от состава батча. Иначе одно и то же при одном и том же зрителе даёт разные числа, скор нельзя кэшировать, результат невоспроизводим, а отладка становится невозможной.
Обратная сторона: модель не видит слейт целиком и не может учитывать взаимное влияние постов — например, что три поста подряд про одно и то же. Эту задачу решают отдельно, переранжированием после скоринга. То есть listwise-эффекты не потеряны, а вынесены в отдельный шаг, где их проще контролировать.
Дополнение · Открытый код ленты «For You»: что это и как читать
Зачем хеш-эмбеддинги, если можно вести словарь идентификаторов?
Два довода. Память: словарь на все посты и всех авторов не помещается, а хеш-таблица имеет фиксированный размер. Свежесть: новый пост представим сразу после публикации, потому что его хеш считается на месте, а словарь пришлось бы пересобирать.
Плата — коллизии. Опасны не любые, а коллизии двух частых значений: их эмбеддинги склеиваются. Лечится несколькими хеш-функциями — тогда неразличимость требует совпадения во всех таблицах сразу. Подробно с интерактивом — глава «Категориальные признаки и память».
Дополнение · Открытый код ленты «For You»: что это и как читать
Что важнее для понимания системы: код пайплайна или веса модели?
Вопрос с подвохом, и правильный ответ — «ни то, ни другое по отдельности». Код пайплайна показывает, какие решения приняты и где рычаги. Веса модели показывают, что система выучила из данных. Открыт только первый, и это принципиальное ограничение прозрачности: можно проверить, что правило существует и как оно написано, но нельзя проверить, чему модель научилась на логах.
Именно поэтому к открытию кода прилагается инструмент, показывающий пользователю лейблы на его аккаунте: код плюс наблюдаемые выходы дают больше, чем один код.
Дополнение · Пайплайн: как лента собирается из типовых стадий
Спроектируйте пайплайн рекомендательного сервиса. Какие стадии и в каком порядке?
Гидратация запроса → источники кандидатов → гидратация кандидатов → дешёвые фильтры → скоринг → отбор топ-K → дорогая гидратация → дорогие фильтры → ответ → побочные эффекты в фоне.
Ключевые обоснования порядка: источники не зависят друг от друга и идут параллельно; фильтры зависят и идут по очереди, от дешёвых к дорогим; всё, что требует похода в другой сервис по паре «объект и пользователь», ставится после отбора, потому что там на порядок меньше объектов; всё, что нужно не текущему запросу, а следующему, уходит в фон после отправки ответа.
Дополнение · Пайплайн: как лента собирается из типовых стадий
Что должно происходить, если один из источников кандидатов недоступен?
Запрос должен выполниться на оставшихся источниках. Рекомендация — не транзакция: неполная лента лучше ошибки. В разобранном коде это буквально одна строка: результаты источников проходят через flatten, который отбрасывает ошибки.
Но обязательное дополнение: деградация должна быть видимой. Нужны метрики по каждому источнику — сколько раз вызвали, сколько раз получили ошибку, сколько кандидатов вернулось. Без этого падение источника на нескольких процентах трафика не обнаружится никогда, а часть пользователей будет систематически получать худшую ленту.
Дополнение · Пайплайн: как лента собирается из типовых стадий
Зачем нужен метод «включён ли» у каждой стадии?
Для экспериментов. Метод получает запрос и решает по нему — внутри он читает параметры конфигурации, а те раскатываются на процент трафика. Таким образом любую стадию можно включить или выключить на части пользователей, не разветвляя код и не выкатывая новую версию.
Второе применение — условная логика: источник тем работает только для тематических запросов, фильтр видео — только когда клиент попросил исключить видео. Вместо ветвлений внутри стадии условие поднимается на уровень фреймворка, где оно ещё и автоматически попадает в метрики.
Дополнение · Пайплайн: как лента собирается из типовых стадий
Почему гидратация запроса разбита на два круга?
Потому что часть гидраторов зависит от результатов других. Взаимные подписки нельзя посчитать, не получив список подписок; последовательность действий для модели строится с учётом уже показанных постов.
Внутри круга гидраторы параллельны, между кругами — барьер. Это упрощённая замена графу зависимостей: настоящий граф требовал бы описывать связи явно, а два уровня покрывают почти все случаи и не требуют ничего, кроме размещения гидратора в нужный список.
Дополнение · Пайплайн: как лента собирается из типовых стадий
Как измерять, где пайплайн теряет время?
Замеры должны быть встроены во фреймворк, а не расставлены руками по стадиям. В разобранном коде у каждого типа стадии есть обёртка run, которая вызывает содержательный метод и попутно фиксирует время, число входных и выходных кандидатов, а также имя стадии.
Отсюда бесплатно получаются ответы на два главных вопроса эксплуатации: где узкое место по времени и какой фильтр сколько выбрасывает. Причём при параллельном выполнении оптимизировать надо худшую стадию, а не среднюю — общее время равно времени самой медленной.
Дополнение · Источники: откуда вообще берутся посты
Зачем в системе несколько кандидатогенераторов, если один из них — обученная модель?
У каждого своя слепая зона. Модель retrieval ищет похожее на историю пользователя, но её индекс обновляется только при сохранении чекпоинта — совсем свежих постов там нет. Источник постов подписок видит только подписки. Кластерный источник опирается на структуру графа и не знает про новое, потому что кластеры пересчитываются раз в неделю.
Убрав любой, получаем класс постов, который система перестаёт находить в принципе. Это ровно тот довод про многомодальный поиск, что и в главе «Виды кандидатогенераторов»: разные генераторы оптимизированы под разные типы связи, и объединение покрывает больше, чем лучший из них.
Дополнение · Источники: откуда вообще берутся посты
Как отдавать посты подписок за единицы миллисекунд?
Не искать, а держать в памяти, разложенным по авторам. В разобранной системе это отдельный сервис: карта «идентификатор поста → компактная запись» плюс индексы «автор → очередь его недавних постов». Запрос сводится к обходу списка подписок и чтению готовых очередей.
Три решения делают это возможным. Никакого текста — только идентификаторы, время и флаги, иначе память вырастет на порядок. Окно хранения — старое вычищается фоновой задачей. Раздельные индексы для оригинальных постов, ответов с репостами и видео — чтобы фильтрация была выбором структуры, а не проходом с отбрасыванием.
Наполняется это потоком событий, а не периодической выгрузкой: задержка от публикации до появления в памяти — секунды.
Дополнение · Источники: откуда вообще берутся посты
Что такое SimClusters и чем он отличается от матричной факторизации?
Это кластеризация социального графа: 20 миллионов заметных аккаунтов раскладываются по 145 тысячам сообществ разреженной бинарной факторизацией графа похожести. Автор получает набор сообществ, за которые он известен; пользователь — набор сообществ, которыми интересуется, как сумму по тем, на кого подписан; пост — вектор над сообществами.
Отличия от обычной факторизации: значения бинарные, а не вещественные; ненулевых координат мало; и главное — координата интерпретируема, это конкретное сообщество с составом, которое можно посмотреть глазами. Плюс раскладывается не матрица взаимодействий, а граф похожести аккаунтов.
Дополнение · Источники: откуда вообще берутся посты
Зачем держать в системе источник, пересчитываемый раз в неделю?
Ради устойчивости и объяснимости. Медленно меняющийся источник — страховка на случай, когда модель после переобучения начинает вести себя неожиданно: часть ленты остаётся предсказуемой.
Объяснимость важна не меньше: «этот пост популярен в сообществе, к которому вы относитесь» можно показать пользователю и предъявить регулятору, а «скалярное произведение эмбеддингов 0.83» — нельзя.
Цена — недельная задержка: новый аккаунт попадёт в кластеры не сразу. Поэтому источник и не единственный.
Дополнение · Источники: откуда вообще берутся посты
Можно ли переиспользовать посчитанный скор при следующем запросе?
Только если скор зависит от пары «пользователь и пост» и ни от чего больше. В разобранной системе это обеспечено маской внимания: кандидаты не видят друг друга, поэтому скор не зависит от состава батча. Благодаря этому в пайплайне есть отдельный источник, отдающий уже отранжированные посты из кеша.
Второе условие — признаки не должны меняться быстрее, чем живёт кеш. Поэтому возраст поста подаётся модели с гранулярностью в час: непрерывно растущее значение обесценивало бы кеш каждую минуту.
Практический смысл: человек листает ленту, каждый экран — новый запрос, и пересчитывать модель на тех же кандидатах бессмысленно.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Почему в двухбашенной модели отказались от обучаемого эмбеддинга пользователя?
Три причины. Холодный старт: новый пользователь получил бы случайный вектор, а история из одного действия уже даёт осмысленное представление. Свежесть: обучаемый вектор отражает пользователя на момент переобучения, история — прямо сейчас. Память: таблица на сотни миллионов строк, которую надо синхронизировать между репликами.
По сути модель переводится из трансдуктивной в индуктивную: она умеет считать вектор для человека, которого не видела при обучении. Цена — устойчивые долгосрочные интересы, не проявленные в недавних действиях, теряются. Для ленты, где контент живёт часы, размен оправдан.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Что такое semantic ID и чем он лучше хеша идентификатора?
Это код из остаточного квантования: мультимодальный эмбеддинг поста кодируется шестью уровнями по 256 кодов. На каждом уровне берётся ближайший вектор кодбука, вычитается, остаток кодируется следующим уровнем.
Отличие от хеша принципиальное. Хеш произволен — два поста про одно и то же попадают в несвязанные строки, и модель должна выучить каждый отдельно. Semantic ID выводится из содержимого, поэтому у похожих постов общий префикс, и знание, накопленное на одних постах, переносится на новые с тем же префиксом. Плюс новому посту код выдаётся сразу после публикации, без переобучения.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Зачем нужны и in-batch, и глобальные негативы, если in-batch бесплатны?
У них разные распределения. In-batch — это чужие позитивы, то есть распределение пропорционально популярности: негативы получаются трудными, но систематически смещёнными к голове. Глобальные сэмплируются из всего корпуса и покрывают хвост, который в батч практически не попадает.
Использовать только in-batch — значит учить модель различать популярное между собой и не знать ничего про хвост. Только глобальные — значит получить слишком лёгкие негативы и слабый градиент. В этом конфиге работают in-batch плюс 64 глобальных на пример, и для каждого вида своя LogQ-поправка.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Зачем LogQ-коррекция и почему её масштаб равен двум?
In-batch негативы приходят из распределения популярности \(Q\). Без поправки sampled softmax сходится к \(\log p - \log Q\), то есть модель систематически занижает популярное. Коррекция вычитает \(\log Q\) из логита и возвращает \(\log p\).
Масштаб 2.0 вместо теоретического 1.0 — это перекоррекция. Мотив: несмещённость достигается относительно распределения, породившего лог, а лог порождён предыдущей версией системы, которая сама любила популярное. Перекоррекция грубо давит этот унаследованный перекос и толкает retrieval в хвост; риск невелик, потому что дальше работает ранжирование.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Почему для retrieval позитив — только лайк, а не любое взаимодействие?
Потому что у retrieval и ранжирования разные задачи. Retrieval должен грубо очертить область интересов — здесь важна полнота, а не тонкость. Ранжирование потом разберётся, что внутри этой области лучше.
Использовать все сигналы сразу на этапе retrieval означало бы смешивать разные по смыслу вещи (клик и досмотр говорят о разном) и усложнять задачу там, где сложность не окупается. Плюс лайк — самый частый явный сигнал, то есть даёт больше всего обучающих примеров.
Отдельно отмечу фильтр: позитив засчитывается, только если на том же посте не было негативного действия, включая «не задержался». Это отсекает кликбейт — пост, который лайкнули и тут же пролистнули.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Зачем хранить индекс кандидатов внутри чекпоинта модели?
Ради согласованности. Веса башни и индекс всегда из одного момента обучения — невозможна ситуация, когда вектор пользователя считает новая башня, а индекс построен старой. Скалярное произведение между несогласованными представлениями бессмысленно, и это классический источник тихой деградации качества.
Побочные выгоды: один артефакт вместо двух, откат модели откатывает индекс, старт сервинга не требует часов на переиндексацию. Цена — индекс обновляется только при сохранении, поэтому свежие посты приходят из других источников.
Дополнение · Retrieval: две башни, хеши и semantic IDs
Векторы обеих башен нормируются. Что это меняет?
Скалярное произведение становится косинусом. Первое следствие — уходит popularity bias: в ненормированном случае длина вектора выучивает популярность, и скалярное произведение систематически предпочитает популярное независимо от направления.
Второе следствие инженерное: на единичной сфере \(\|a-b\|^2 = 2 - 2\langle a,b\rangle\), то есть поиск максимума скалярного произведения сводится к поиску ближайшего по евклиду. Можно брать любую библиотеку приближённого поиска, не требуя от неё поддержки MIPS.
Цена — теряется сигнал, который несла норма. Отличить надёжный, много раз подтверждённый вектор от шумного вектора холодного айтема после нормировки нечем.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Как реализована изоляция кандидатов и что она даёт?
Одним условием в ядре внимания: запрос видит ключ, только если ключ принадлежит истории либо ключ — это сам запрос. Кандидаты физически не могут смотреть друг на друга; блоки «кандидат × чужой кандидат» даже не загружаются в вычисление.
Даёт четыре вещи. Кэшируемость: скор зависит только от пары (пользователь, пост). Воспроизводимость: порядок не зависит от разбиения на батчи. Объяснимость: причина позиции не содержит слов «рядом оказался другой пост». Скорость: исчезает квадратичный член по кандидатам.
Цена — модель не видит слейт и не может учесть взаимное влияние постов. Эту задачу выносят в отдельный шаг переранжирования после скоринга.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Почему конверсионные головы обучаются только на кликнутых постах?
Потому что «конверсии не было» на посте без клика не означает отказа — у пользователя не было возможности совершить действие. Обучая на таких примерах, мы учим модель, что отсутствие возможности равно отрицательному ответу, и получаем систематическое занижение.
Это классический сдвиг выборки, тот же, ради которого придумали ESMM. Здесь выбран путь маскирования лосса: голова получает градиент только на подвыборке с кликом, при этом общее тело модели видит все примеры, так что представления учатся на полных данных.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Зачем разные оптимизаторы для плотных слоёв и эмбеддингов?
У них различается частота получения градиента на порядки. Плотная матрица обновляется на каждом примере батча; конкретная строка таблицы эмбеддингов — только когда её айтем попал в батч.
Поэтому эмбеддингам нужен большой шаг и адаптация по каждой строке отдельно — построчный Adagrad со скоростью 0.28, тогда как плотная часть учится Muon со скоростью 7.1e-4, почти в четыреста раз меньшей. Единый оптимизатор либо задушит эмбеддинги малым шагом, либо развалит плотную часть большим.
Отдельная тонкость — период полураспада статистики Adagrad в 2500 шагов: без забывания знаменатель растёт монотонно, эффективный шаг уходит в ноль, и модель перестаёт адаптироваться к изменившемуся поведению.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Что предсказывается регрессией, а не классификацией, и зачем?
Время задержки, время после клика, активные секунды — восемь непрерывных величин. Их нельзя загонять в бинарную постановку, не потеряв главное: разницу между «посмотрел три секунды» и «посмотрел тридцать». Любой порог эту разницу стирает.
Платят за это тяжёлым хвостом распределения и необходимостью согласовывать масштаб с вероятностями в общей сумме. Первое лечится метриками, устойчивыми к выбросам, второе — маленьким весом при непрерывном слагаемом в формуле скора.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Зачем добавлять шум в признак «час дня»?
Потому что час — искусственная дискретизация непрерывного времени. Разница между 13:59 и 14:01 для поведения нулевая, но для признака это переход между значениями, и модель выучивает резкие границы на круглых часах — артефакт разметки, а не свойство пользователей.
Десятипроцентное дрожание размывает границы и вынуждает учить плавную зависимость. Тот же мотив, что у кусочно-линейного кодирования непрерывных признаков, только приём грубее и универсальнее: он не требует менять архитектуру.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Ранкер вдвое шире ретривера. Почему не наоборот?
Из-за числа объектов, которые каждый должен обработать. Ретривер выдаёт эмбеддинги для 28 миллионов постов; удорожание его башни умножается на этот корпус. Ранкер работает с сотнями кандидатов на запрос, и стоимость лишней ширины несопоставимо меньше.
Это общий принцип многостадийных систем, который мы разбирали в главе «Многостадийная воронка»: чем дальше по воронке, тем меньше объектов и тем дороже допустимая модель. Стадия отбора обязана быть простой, стадия ранжирования может себе позволить сложность.
Дополнение · Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
История в модели двусторонняя, без причинной маски. Не утечка ли это?
Нет. Утечка была бы, если бы модель при предсказании использовала информацию из будущего относительно момента предсказания. Здесь вся история — прошлое по отношению к оцениваемым кандидатам; внутри неё событию из середины смотреть на более позднее совершенно законно.
Причинная маска нужна авторегрессионным моделям, предсказывающим следующий элемент последовательности. Здесь задача другая: закодировать историю целиком, а предсказание снимается с позиций кандидатов, которые стоят после всей истории.
Дополнение · Скоринг: из вероятностей действий в одно число
Почему нельзя сказать, что жалоба перечёркивает 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-теста, а тестов на всю решётку значений не хватит.
На практике смотрят не на вовлечение в моменте, а на долгосрочные показатели — возвращаемость, время до следующей сессии, доля негативных реакций. Это ровно тот разговор про прокси-метрики и их расхождение с настоящей целью, который был в главе «Прокси-метрики и долгосрочные цели» и глава «Истинная релевантность и её прокси». Тот факт, что вес жалобы настолько велик, а сумма негативных весов в девять раз превышает сумму позитивных, — как раз попытка защититься от оптимизации сиюминутного вовлечения.
Дополнение · Фильтрация: двадцать девять поводов не показать пост
В каком порядке ставить фильтры в рекомендательном пайплайне?
По двум осям сразу. По стоимости: сначала те, кому не нужны внешние данные, потом требующие похода в другой сервис. По отсеву на единицу стоимости: если фильтр дешёвый и выбрасывает пятую часть — он должен быть в начале, чтобы уменьшить работу для всех последующих.
Дедупликация обычно идёт самой первой: источники работают параллельно и возвращают пересекающиеся множества. Фильтры, срабатывающие лишь для части запросов, разумно ставить ближе к концу — чаще всего они не делают ничего.
Отдельная граница — отбор топ-K. Всё, что требует запроса по паре «объект и пользователь», ставится после него: разница между тысячами кандидатов и десятками отобранных даёт экономию в десятки раз.
Дополнение · Фильтрация: двадцать девять поводов не показать пост
Почему отбирают больше постов, чем показывают?
Потому что после отбора работают ещё фильтры, и сколько они выбросят, заранее неизвестно. В разобранной системе отбирается 50, а на экран уходит 35 — запас примерно двукратный по отсеву последней стадии.
Если отбирать ровно 35, то любое срабатывание фильтра видимости оставит пользователя с неполным экраном. Практическое правило: каждое новое правило, применяемое после ранжирования, — это налог на размер отбора, и его надо либо оплачивать увеличением K, либо осознанно принимать риск.
Дополнение · Фильтрация: двадцать девять поводов не показать пост
Как гарантировать, что пользователь не увидит один и тот же пост дважды?
Никак не гарантировать одним механизмом — запись показов происходит после отправки ответа, в фоне, и может не случиться. В разобранной системе на эту задачу работают четыре независимых механизма: компактный фильтр Блума, приезжающий с запросом, два фильтра из разных журналов показов и отдельный фильтр по уже отданному в текущей сессии скролла. Плюс источнику постов подписок список показанного передаётся внутрь, и он их не возвращает вовсе.
Логика в том, что пути данных разные: чтобы дубликат прорвался, должны отказать все сразу. Цена невелика, а повторный показ пользователь замечает мгновенно.
Дополнение · Фильтрация: двадцать девять поводов не показать пост
Как измерить, сколько ценности приносит определённый тип контента в ленте?
Наблюдением — нельзя. Доля вовлечения, которую собирают, скажем, ответы, завышает их вклад: убрав их, вы освободите позиции, которые займут другие посты, и часть вовлечения перетечёт туда.
Нужен эксперимент: убрать этот тип у части трафика и сравнить. В разобранном коде для этого есть отдельный фильтр, детерминированно скрывающий заданный процент постов. Две важные детали реализации: решение зависит от хеша пары «пост и зритель», поэтому оно стабильно — один и тот же пост не мелькает между запросами; и оно персонально — пост скрыт для конкретного человека, а не глобально, поэтому общая статистика поста не искажается.
Необычно здесь то, что единица рандомизации — пара «пользователь и объект», а не пользователь. Это позволяет мерить ценность типа инвентаря, а не функциональности.
Дополнение · Фильтрация: двадцать девять поводов не показать пост
Фильтр по возрасту выбрасывает всё старше 48 часов. Не теряем ли мы хорошее?
Теряем, и осознанно. Это продуктовое решение о том, что такое лента новостей: контент старше двух суток в ней неуместен, каким бы хорошим он ни был.
Инженерно у решения есть приятные следствия. Оно ограничивает объём, который надо держать в оперативной памяти сервису свежих постов. Оно даёт верхнюю границу на размер журналов показов. И оно почти бесплатно: время создания зашито в идентификатор поста, никаких данных запрашивать не нужно — поэтому фильтр и стоит третьим, сразу после дедупликации.
Обратная сторона — система структурно не способна показать вам хороший пост недельной давности. Для ленты это правильно, для, скажем, видеосервиса было бы катастрофой. Порог такого рода всегда следует из продукта, а не из техники.
Дополнение · Разметка и видимость: можно ли показать этот пост
Зачем в системе модерации три возможных ответа, а не два?
Промежуточный вариант — показать за заглушкой — переносит решение на пользователя и резко снижает цену ошибки классификатора. В двухвариантной системе ложное срабатывание означает либо скрытый нормальный пост, либо показанный неприемлемый; с заглушкой оно стоит одного лишнего нажатия.
Практическое следствие: порог детектора можно ставить агрессивнее и ловить больше, не платя за это раздражением пользователей. То есть третий ответ — это не про интерфейс, а про рабочую точку на кривой точность-полнота.
Дополнение · Разметка и видимость: можно ли показать этот пост
Как использовать сразу два детектора одного и того же — с высокой точностью и с высокой полнотой?
Применять их в разных ситуациях, исходя из асимметрии цены ошибки. В разобранной системе к постам от аккаунтов, на которые зритель подписан, применяется только точный детектор: ложное срабатывание тут дорого, человек сам выбрал читать этого автора. К рекомендациям от незнакомых аккаунтов добавляется детектор с высокой полнотой: ложное срабатывание почти бесплатно, потому что пользователь этого поста не ждал, а вот пропустить спам, который система сама подсунула, заметно.
Вместо выбора одной точки на кривой берутся две и применяются там, где каждая уместна. Важная деталь: дополнительный набор правил может только запрещать — строгость растёт по мере удаления от круга подписок, но никогда не падает.
Дополнение · Разметка и видимость: можно ли показать этот пост
Как оценить репутацию аккаунта и почему нужно несколько способов?
В разобранной системе их три, и они опираются на принципиально разные данные. По реакции других: отношение жалоб к лайкам за 30 дней — именно отношение, иначе крупные аккаунты наказывались бы за размер. По структуре графа: PageRank по подпискам и взаимодействиям, потом логарифм массы. По собственному поведению: трансформер над последовательностью действий аккаунта.
Несколько нужно потому, что у каждого своя уязвимость. Реакция других подделывается скоординированными жалобами. Граф подделывается накруткой подписчиков, хотя и плохо — PageRank учитывает вес источника, а масса ботов близка к нулю. Поведение подделывается имитацией человеческого ритма, что дорого в масштабе. Обмануть все три одновременно значительно труднее, чем любой один.
Дополнение · Разметка и видимость: можно ли показать этот пост
Чем детектор ботов похож на рекомендательную модель?
Архитектурно — это одно и то же: трансформер над последовательностью действий пользователя. Разница только в целевой переменной: рекомендательная модель предсказывает, что человек сделает дальше, детектор — человек ли это вообще.
Общая интуиция тоже одна: последовательность содержит информацию, которую агрегаты уничтожают. Для рекомендаций это порядок и темп интересов, для детекции — ритм: равномерные интервалы, отсутствие пауз на сон, повторяющиеся цепочки действий. Каждое отдельное действие бота выглядит нормально, выдаёт его именно последовательность.
Дополнение · Разметка и видимость: можно ли показать этот пост
Почему решение о видимости не встроено в ранжирующую модель?
Четыре причины, и все видны в коде. Разная цена ошибки — понижение скора и запрет показа несопоставимы по последствиям. Разная скорость изменений — правила меняются по требованиям закона за часы, модель переобучается по расписанию. Проверяемость — правило можно прочитать и оспорить, понижение скора нельзя. Гарантия — понижение скора не гарантирует, что пост не покажут: если конкурентов мало, он всё равно окажется в выдаче.
Последний пункт часто упускают, а он решающий. Жёсткое требование «не показывать» реализуется только жёстким фильтром; мягкое занижение такого обещания не даёт.
Дополнение · Блендинг: лента состоит не только из постов
Как встроить рекламу в ранжированную ленту?
Отдельной стадией после ранжирования, правилами, а не общим скором. Причины: у скора поста и ставки рекламодателя нет общей единицы измерения, коэффициент пересчёта пришлось бы назначить произвольно; у рекламы есть договорные обязательства, которые нельзя перебить скором; доля рекламы — стратегическое решение, которое нельзя отдавать модели.
В разобранной системе количество рекламы — минимум из трёх ограничений: сколько её есть, что позволяет шаг расстановки и сколько в ленте безопасных для бренда постов, причём последнее даёт потолок в половину. Плюс порог: если постов меньше пяти, рекламы нет вовсе.
Дополнение · Блендинг: лента состоит не только из постов
Зачем ограничивать рекламу долей от «безопасных» постов?
Чтобы соблюсти обязательства перед рекламодателями по соседству и одновременно связать монетизацию с качеством выдачи. Механизм жёсткий: плохой контент уменьшает не расстановку, а общее число размещаемой рекламы.
Экономически это означает, что лента с проблемным содержимым автоматически теряет способность зарабатывать. Такой контур обратной связи работает надёжнее любых деклараций, потому что встроен в код, а не в политику.
Дополнение · Блендинг: лента состоит не только из постов
Как часто показывать блок рекомендаций аккаунтов?
Не по скору, а по фиксированной позиции с механизмом усталости. В разобранной системе позиция шестая — в пределах первого экрана, но не на первом месте, — а интервал повторного показа 30 часов.
Некратность суткам здесь не случайна: интервал в 24 часа привязал бы показ к одному и тому же времени дня, а 30 часов дают дрейф, и блок попадает в разные контексты. Смысл ограничения в том, что ценность модуля не в частоте показа, а в том, чтобы он сработал хотя бы раз; ежедневный показ выработал бы слепоту.
Дополнение · Блендинг: лента состоит не только из постов
Что должно происходить после отправки ответа пользователю?
Всё, что нужно не текущему запросу, а следующим: запись показов, обновление кешей, публикация обучающих логов, метрики, события для биллинга. В разобранной системе таких задач тринадцать, и они запускаются в фоне без ожидания результата.
Обязательное следствие, о котором надо помнить: раз результат не проверяется, всё это ненадёжно. Для защиты от повторного показа поэтому держат несколько независимых механизмов. А для обучающих логов надёжного решения нет вовсе — их потеря смещает выборку для будущих моделей и не видна ни в одной онлайн-метрике.
Дополнение · Конфигурация: как система меняется, не меняясь
Как организовать конфигурацию рекомендательной системы, чтобы можно было экспериментировать?
Всё, что может стать предметом эксперимента, выносится в параметры, читаемые из внешней системы по конкретному запросу, а не глобально. Тогда у двух одновременных запросов значение одного параметра может отличаться, и это даёт разбиение на группы без ветвлений в коде.
Второе: каждая стадия пайплайна получает выключатель, читающий параметр. Это позволяет экспериментировать не только со значениями, но и с наличием стадии — новый источник или фильтр включается на проценте трафика.
Третье, о чём часто забывают: замеры должны сниматься на уровне фреймворка, иначе вместе с экспериментом придётся руками добавлять измерение его эффекта.
Цена подхода — дисциплина смещается из кода в процесс работы с конфигурацией: изменение поведения продукта больше не проходит ревью кода.
Дополнение · Конфигурация: как система меняется, не меняясь
Если значения живут во внешней конфигурации, какой смысл в открытом коде?
Сам по себе — небольшой: вы увидите структуру и не увидите чисел. Поэтому в разобранном репозитории отдельно оговорено, что дефолты приводятся к основным продовым значениям скриптом по расписанию.
Важно правильно понимать, что это даёт. Формулировка «так работает для большинства пользователей на момент последней синхронизации» — верна. Формулировка «так работает» — нет: часть трафика в экспериментах, и между изменением в проде и его появлением в коде есть лаг.
Дополнение · Конфигурация: как система меняется, не меняясь
Расскажите про случай, когда хорошие метрики скрывали проблему.
Хороший пример есть прямо в этом репозитории. Летом 2026 года ввели прибавку к весу вероятности ответа для постов от людей с взаимной подпиской: сначала значение 20, потом снизили до 15.
Причина снижения — не метрики. Они, судя по описанию, были хорошими: люди активнее общались со знакомыми. Проблема выявилась из обратной связи: во время чемпионата мира пользователи стали видеть недостаточно обсуждения события, потому что много релевантных постов писали аккаунты, на которые они не подписаны, а усиление взаимных подписок автоматически ослабило всё остальное.
Мораль ровно та, что в разговоре про прокси-метрики: оптимизируемая величина росла, а функция продукта — показывать, что происходит в мире — деградировала, и ни одна метрика вовлечения этого не показывала.
Дополнение · Конфигурация: как система меняется, не меняясь
Как понять, что параметр в коде стоит нулём: это выключено или сломано?
Ноль обычно означает одно из трёх, и различать их важно. Механизм проверялся и не поехал — так в разобранном репозитории обстоит с прибавкой к весу времени задержки: её тестировали в том же эксперименте, что и прибавку к ответам, но широко не раскатили. Голова обучается, но в скор не входит — как у клика по профилю: предсказание считается, вес нулевой. Механизм ждёт включения — как холдаут инвентаря с процентами по нулям.
Общее в трёх случаях то, что ноль — это положение ручки, а не отсутствие ручки. Код механизма написан, протестирован и готов; завтра значение может стать ненулевым без единой строки изменений.
Спроектируйте ленту социальной сети. С чего начнёте?
С двух разделений, которые определяют всё остальное.
Первое: ранжирование отдельно от видимости. Порядок решает модель, право на показ — отдельный сервис с правилами. Разная цена ошибки, разная скорость изменений, разная проверяемость. И главное: понижение скора не гарантирует, что пост не покажут.
Второе: посты отдельно от не-постов. Реклама, блоки рекомендаций аккаунтов, промо не участвуют в общем ранжировании — у них нет общей единицы измерения со скором поста, а их доля есть решение бизнеса.
Дальше стандартная воронка: несколько источников с разными слепыми зонами → гидратация → дешёвые фильтры → модель → отбор с запасом → дорогие фильтры → блендинг. И побочные эффекты после ответа.
Как представить пользователя и айтем, если контент живёт часы?
Не обучаемыми векторами. Пользователь — выход трансформера над последовательностью его действий: это работает с первого действия и обновляется в реальном времени. Айтем — код, выводимый из содержимого, например остаточное квантование мультимодального эмбеддинга: новый пост получает представление сразу после публикации, а близкие по смыслу посты делят префикс кода, поэтому знание переносится.
Общее правило: обучаемый вектор оправдан, когда объект живёт дольше периода переобучения. Если короче — он не успевает обучиться и бесполезен.
Модель предсказывает десяток разных действий. Как свести их в один скор?
Взвешенной суммой с весами в конфигурации, а не в лоссе. Это развязывает модель и продуктовую политику: изменение приоритетов становится изменением числа и раскаткой эксперимента, а не переобучением.
Три вещи, о которых надо сказать отдельно. Веса умножаются на вероятности, а не на счётчики, поэтому из отношения весов нельзя делать вывод о влиянии. Результат стоит приводить к неотрицательной области, если дальше он умножается на поправки меньше единицы. И веса нельзя вывести теоретически — только экспериментом, что и является главной ценой схемы.
Мягким множителем с полом, а не квотой: \(m(k) = (1-\text{floor})\cdot\text{decay}^k + \text{floor}\), где \(k\) — сколько постов того же автора уже стоит выше. При значениях 0.5 и 0.25 получается 1.0 → 0.625 → 0.438 → 0.344 с полом 0.25.
Преимущество перед квотой: это цена, а не запрет. Достаточно хороший пятый пост автора пройдёт, если обгонит чужие даже с коэффициентом 0.25. Пол нужен, чтобы автор не исчезал совсем.
И отдельно стоит сказать, что это только одна ось разнообразия. Однообразие по темам решается другим механизмом — отбором через детерминантный процесс после скоринга.
Дать ровно одному их посту гарантированную позицию в середине выдачи, а не множитель ко всем. Реализация: из подходящих постов (мало показов, мало подписчиков у автора, свежий) берётся лучший по скору, и его скор приравнивается к скору позиции 15–16.
Два свойства делают это управляемым. Гарантия позиции, а не прибавки: результат предсказуем и не зависит от того, кто ещё в выдаче. Ровно один пост: цена механизма ограничена сверху одной позицией независимо от числа новичков.
По смыслу это exploration: тратим позицию, чтобы получить сигнал о посте, о котором данных нет. В коде рядом лежит и байесовский вариант выбора через сэмплирование Томпсона с априором Beta(0.75, 49.25), выключенный по умолчанию.
Пользователь жалуется, что видит один и тот же пост дважды. Где искать?
Начать надо с того, что запись показов почти наверняка происходит после отправки ответа, в фоне, и её результат не проверяется. Значит, показ мог не записаться: перезапуск сервиса, переполнение очереди, отказ хранилища.
Отсюда практика: несколько независимых механизмов с разными путями данных. В разобранной системе их четыре — компактный фильтр, приезжающий с запросом, два журнала показов из разных хранилищ и сессионный список. Плюс источнику постов подписок список показанного передаётся внутрь.
Диагностика соответственно: сравнить, что записалось в каждый из журналов, и искать расхождение, а не искать баг в фильтре.
Как измерить, сколько ценности приносит определённый тип контента?
Только экспериментом, наблюдением нельзя: убрав тип контента, вы освободите позиции, которые займут другие посты, и часть вовлечения перетечёт туда. Наблюдаемая доля завышает вклад.
Механизм: детерминированно скрывать заданный процент таких постов и сравнивать. Две важные детали — решение должно зависеть от хеша пары «объект и пользователь», чтобы быть стабильным между запросами, и должно быть персональным, чтобы не искажать общую статистику объекта.
Единица рандомизации здесь пара «пользователь и объект», а не пользователь. Это позволяет мерить ценность инвентаря, а не функциональности.
Метрики выросли, а пользователи жалуются. Что делать?
Признать, что метрика измеряет не то, ради чего продукт существует, и искать, какая функция продукта деградировала.
Конкретный случай из этого репозитория: усиление веса ответа для взаимных подписок дало хорошие метрики вовлечения — люди активнее общались со знакомыми. А жалобы были на другое: во время крупного спортивного события лента показывала мало обсуждения, потому что релевантные посты писали неподписанные аккаунты, и усиление одной группы автоматически ослабило остальные. Значение снизили с 20 до 15.
Общий вывод: усиление любой группы есть ослабление всех прочих, и заметно это становится там, где метрик нет. Полезно держать метрики покрытия и разнообразия рядом с метриками вовлечения — они как раз ловят такие сдвиги.
Вопрос на зрелость суждения, и отвечать «всё правильно» плохо. Три содержательных направления.
Тихая деградация. Ошибки источников проглатываются молча. Это верное решение по существу, но оно требует метрик и алертов на каждый источник — иначе отказ на паре процентов трафика не обнаружится. По открытому коду видно, что замеры есть; видно ли по ним такие отказы — из репозитория судить нельзя.
Надёжность обучающих логов. События для будущих моделей пишутся тем же ненадёжным фоновым способом, что и всё остальное. Потеря части логов смещает выборку и не диагностируется онлайн-метриками. Здесь я бы разделил логирование для продукта и логирование для обучения, дав второму гарантии доставки.
Отсутствие явного позиционного дебиасинга. Модель учится на логах собственной выдачи, где верхние позиции получают больше внимания просто из-за позиции. В открытом коде явной поправки не видно — возможно, она есть в неопубликованной части, но если нет, это заметный источник смещения.