Привет, Хабр! Я Степан Малькевич, руковожу командой рекомендации ленты ВКонтакте, AI VK. Сегодня расскажу, как мы за последний год прокачали алгоритмы рекомендаций для историй. Это был путь от MVP с простыми эвристиками до ML‑системы с моделированием авторов, поведенческими фичами и прицелом на онлайновые реакции.

MVP и первые шаги к ранжированию

Когда мы только запустили истории в ленте в формате MVP, ранжирование строилось на показе пользователю историй от друзей и сообществ просто по времени (свежие сверху). Такой подход годился для проверки гипотезы и сбора первичных данных, но лента историй требует персонализации. Из сотен подписок не все одинаково интересны пользователю. Простое решение через эвристику тоже не давало желаемого эффекта. Нужно было что‑то умнее.

Мы решили поступить так же, как в классической задаче рекомендаций: обучить модель предсказывать рейтинговый скор для каждой истории под конкретного пользователя. На основе этого скоринга можно ранжировать контент. Казалось бы, задача знакомая — чем рекомендации историй отличаются от ранжирования постов? Однако у историй есть нюанс: пользователь обычно просматривает несколько историй одного автора подряд. Если просто отсортировать вообще все истории, перемешав авторов, то получится каша: истории одного и того же человека разлетаются по ленте. Поэтому родилась двухуровневая схема: сперва предсказываем релевантность каждой отдельной истории пользователю, затем группируем истории по авторам и как‑то агрегируем их скоры, после чего уже ранжируем авторов по этому агрегированному значению. Так мы покажем самых интересных авторов первыми, а внутри одного автора истории пойдут подряд. Звучит разумно, но реализация породила множество вопросов — о них дальше.

Первую модель сделали максимально простой: никаких нейросетей, только легковесный классификатор. В качестве признаков взяли то, что было под рукой: базовые сведения о пользователе (возраст, платформа и т. п.), свойства истории (время публикации, тип контента), кое‑что про связь пользователя и автора (например, есть ли недавние взаимодействия). Целевой метрикой сначала выбрали факт просмотра истории — то есть попытались учить модель отличать просмотренные пользователем истории от пропущенных. Обучили модель на первых собранных логах и выкатили. Эффект не заставил себя ждать, по сравнению с хаотичным показом мы подняли просмотры историй на десятки процентов. Алгоритм начал точнее угадывать, какие истории пользователь, скорее всего, не пролистает. Конечно, радость была недолгой: улучшения быстро вышли на плато, а некоторые очевидные проблемы базового решения проявили себя на практике.

Фичи против таргетов: с чем и на что обучать?

После первых успехов мы сели и трезво оценили: чтобы идти дальше, нужно расширять горизонт. Во‑первых, добавить побольше информативных признаков, как про пользователя, так и про автора истории. Во‑вторых, пересмотреть целевую переменную для обучения модели (вдруг мы стреляем мимо?). Собрали мозговой штурм: какие есть данные и сигналы, влияющие на интерес пользователя к историям?

Признаки, доступные нам для обучения, разделились на несколько категорий:

  • User features: характеристики пользователя (пол, возраст, устройство), его общая активность в VK, любовь к форматам историй и постов и т. п.

  • Story features: свойства самой истории — время публикации, тип (фото, видео), длина, наличие интерактивности.

  • Owner (author) features: свойства автора истории — сколько у него подписчиков, сколько историй он выкладывает, тематика контента, средние просмотры.

  • User‑Author features: персональная история взаимодействия пользователя с этим автором — смотрел ли раньше его истории, реагировал ли (лайки/комменты в историях, переходил ли в профиль автора).

  • Content features: например, тематики (теги) истории и интересы пользователя, пересечения по музыке или геолокации, если доступны. В идеале модель должна понимать и о чём история, и насколько пользователь резонирует с темой.

Параллельно нужно было определиться, на какую целевую метрику обучать модель. Казалось бы, цель очевидна: максимизировать просмотры историй. Но обучать напрямую на «просмотр/не просмотр» не очень правильно: в ленте историй просмотр считается по факту показа на экране, а на показ влияет само ранжирование — получается замкнутый круг. Нужен какой‑то прокси, сигнал от пользователя, который означает интерес к истории. В нашем распоряжении целый спектр таких сигналов: лайки, репосты, «Отправить в личку», «Ответить на историю» (т. е. комментарий), досмотр видео в истории до конца, просто открытие истории (тап по аватарке) и т. д. Все они разной силы и частоты: лайк случается редко, но явно значит сильный интерес; досмотр тоже редок, но ценен; открытие истории (клик по ней) происходит гораздо чаще — это слабее сигнал, но массовый.

Основные кандидаты: лайк, репост, ответ на историю, скриншот истории, досмотр видео и т. п. Хотелось учесть всё. Сначала мы попробовали идти очевидным путём: обучать несколько раздельных моделей — например, отдельно на лайк, отдельно на поделиться — а потом как‑то объединять их предсказания. Однако быстро стало ясно, что так мы утонем в сложности. Во‑первых, сколько моделей ни обучай, итог всё равно надо свести к одному числу ранга для истории. Мы попробовали взять взвешенную сумму предсказаний разных моделей, задав веса экспертно. И тут вылезла проблема: если пользователь, допустим, не склонен лайкать истории, то модель по лайкам будет занижать скор интересных ему историй, потому что таргет не подходит для этого пользователя. Смешивая несколько таких подслеповатых моделей, легко получить среднюю по больнице. Во‑вторых, инференс сразу нескольких моделей — непозволительная роскошь по времени, особенно на мобильных клиентах. В общем, пришлось признать: нужен один таргет (или объединённый лосс), который бы максимально коррелировал с нашей звездной метрикой — количеством просмотров.

Тут нас осенило: мы фактически хотим, чтобы пользователь открыл просмотрщик историй и посмотрел как можно больше. Значит, логично предсказывать именно вероятность открытия историй конкретного автора. Если человек нажал на аватарку — всё, он наш. Он, скорее всего, уже досмотрит несколько историй этого автора. Лайки и share — круто, но они бывают у единиц историй, а открытие — базовое действие для потребления контента. Мы начали склоняться к тому, чтобы заменить прежний таргет (лайки/просмотры) на более прямой сигнал. Требовалось проверить гипотезу, но интуиция подсказывала: ранжировать по вероятности открытия (OpenViewer) будет правильным курсом.

Новый таргет: клики вместо лайков

Пришло время сменить наш путь. Мы решили, что модель должна предсказывать событие OpenViewer — переход пользователя в просмотрщик историй конкретного автора. Формально: если пользователь кликнул по «кружочку» автора и начал смотреть его истории, то считаем это положительным откликом для всех историй этого автора (которые были кандидаты на показ). Если не кликнул — отрицательный отклик. Таким образом мы отметили в логах, каких авторов пользователь открыл, а кого проигнорировал. Именно на этом и построили новый датасет для обучения.

Смена таргета заставила переосмыслить сам юнит ранжирования. Раньше мы оценивали релевантность каждой отдельной истории (пусть даже потом агрегировали по авторам). Теперь же событийный таргет — по сути, на уровне автора. Проще говоря, мы решили напрямую ранжировать авторов, а не отдельные истории. Это значительно упрощало жизнь: вместо множества историй одного автора можно оперировать одним объектом — автором — с соответствующими признаками. Да, мы немного теряли в точности (не все истории у автора одинаково хороши), но выигрыш в простоте оказался важнее.

Все признаки историй агрегировали до уровня автора: считали максимум, среднее или суммировали — в общем, превратили множество историй одного автора в один набор фич. Плюс добавили новые признаки по автору (о них чуть позже). Метка таргета — 1, если пользователь открывал этого автора в историях за сессию, 0 если нет. Такое представление сократило размер обучающей выборки (авторов меньше, чем историй) и устранило проблему дублей. К тому же мы естественным образом ограничили количество показов от одного автора: даже если у него 10 историй, система в принципе решает, показывать автора или нет, а уже потом отдаёт все 10 подряд при просмотре.

Переход на новый таргет и автороцентристское ранжирование сразу дал прирост метрик. Мы получили около +15 % просмотров историй сверх предыдущего подхода — существенный шаг. Пользователи стали чаще открывать истории, ведь алгоритм теперь ранжировал именно по вероятности открытия. Интуиция оправдалась: не все интересные истории обязательно вызывают лайк, но если человек хоть чуть заинтригован — он нажмёт и посмотрит. Мы буквально начали рекомендовать тех авторов, чьи истории пользователь с большей вероятностью захочет глянуть, даже если без лайка. Почти сразу посыпались благодарности от некоторых авторов контента — мол, стало больше просмотров их историй. Алгоритмическая лента сделала своё дело. Но останавливаться мы не планировали: следующая цель — учесть максимум целей сразу, но аккуратно.

Парный лосс и мультизадачность модели

После ввода OpenViewer‑таргета мы всё же понимали: другие типы взаимодействий терять жалко. Лайки, репосты, ответы — это же тоже показатели качества контента. Идеально, если мы сможем повысить их тоже, не просаживая просмотры. Поэтому задача превратилась в многокритериальную: оптимизировать основной таргет (открытие истории), но с оглядкой на вторичные (лайки, шаринг и др.). Кроме того, мы решили улучшить сам подход к обучению модели ранжирования. Ранее у нас модель училась предсказывать вероятность (точнее, класс 0/1) для каждого кандидата независимо — это называется pointwise‑подход. Но в ранжировании часто лучше работают pairwise‑методы, когда модель учится сразу на сравнении пар «интересный vs. неинтересный. Мы решили попробовать pairwise‑лосс: чтобы модель училась отдавать бóльший скор тем авторам, которых пользователь открыл, по сравнению с теми, кого проигнорировал.

В итоге финальная схема обучения выглядела так. Мы используем Pairwise Loss внутри одной сессии пользователя: перебираем пары «открытый автор и пропущенный автор» и максимизируем вероятность того, что модель поставит больший скор первому.

Кроме того, мы сделали лосс мультитаргетным: учитываем сразу несколько типов событий. Вместо одной метрики 0/1 у нас сразу несколько меток: открыл, лайкнул, репостнул, и т. д. Для каждой такой метки рассчитываем свой pairwise‑лосс (с тем же принципом сравнения пар) и затем берём их взвешенную сумму. Эту конструкцию прозвали Multi‑Target Pairwise Loss (MPwLoss). Коэффициенты w_like, w_share... при каждом таргете задали экспертно (попросту руками, в соответствии с правильными, с нашей точки зрения, приоритетами: лайк ценнее открытия, репост ещё ценнее и т. п.). Всё это привело к тому, что одна модель сразу училась подстраиваться под комбинацию целей: предпочесть автора, чью историю пользователь открыл и тем более лайкнул, перед автором, чью историю проигнорировал.

Такая хитрая функция потерь имеет несколько преимуществ. Во‑первых, модель стала лучше ранжировать: мы заметили рост offline‑метрик ранжирования (например, NDCG) по сравнению с прежней pointwise‑моделью. Это и ожидалось: pairwise‑оптимизация ближе к тому, чего мы хотим от ранжирования.

Во‑вторых, мы эффективнее использовали возможности модели. Одной модели приходилось учить сразу всё и не размывать внимание по нескольким отдельным задачам, и это пошло ей на пользу. В терминах bias‑variance, совместное обучение на нескольких сигналах немного увеличило variance, зато сильно уменьшило bias от каждого отдельного таргета.

В‑третьих, нам не пришлось держать пачку моделей и мучиться с ансамблями: одна модель делала работу за всех, и время инференса не увеличилось. Ну и самое главное — мы заложили основу, как говорится, «на вырост»: если в будущем хотим добавить ещё таргет, то достаточно добавить ещё слагаемое в лосс, не переписывая половину пайплайна.

Реализовать всё это на производственных данных оказалось нетривиально. Обучение с pairwise‑лоссом сложно выполнять из коробки: стандартные библиотеки типа XGBoost или CatBoost тогда ещё не поддерживали кастомный множественный pairwise‑лосс. К тому же объёмы данных у нас огромные: нужно генерировать пары из миллионов сессий пользователей, что выливается в миллиарды строк для обучения. Мы пошли на инженерный шаг: форкнули XGBoost и пропатчили его под наши задачи, а обучение распределили по кластеру через Spark.

Так появился наш внутренний XGBoost‑Spark с поддержкой мультитаргетного pairwise‑лосса. Технологию мультитаргетного pairwise‑лосса разработал мой коллега Евгений Замятин и подробно рассказал о ней в этом видео. Мы научились подавать в бустинг‑модель сразу массив меток и массив весов для каждой (чтобы реализовать MPwLoss) вместо стандартного одного числа таргета. Обучение распараллеливается по таргетам и парам; мы проделали большую оптимизацию, чтобы не генерировать все пары явным образом, а считать лосс на лету. В итоге обучение модели, которая раньше отняло бы несколько дней, укладывается в считанные часы — ускорение в десятки раз. Без этого решения итерации экспериментов заняли бы вечность, а так мы смогли достаточно быстро крутить гиперпараметры и проверять новые идеи.

Стоит отметить, что качество рекомендаций в онлайне от такого усложнения модели улучшилось не мгновенно. Поначалу мы столкнулись с тонкой подстройкой: например, слишком большой вес лайков в лоссе приводил к лёгкой просадке общих просмотров — модель начинала переоценивать редкие «лайкабельные» истории и меньше показывать просто хорошие, которые пользователь бы смотрел без лайка. Пришлось поварьировать веса, пока не нашли баланс. В итоге финальная модель с MPwLoss дала небольшой плюс к просмотрам (порядка пары процентов), но заметно подняла вторичные показатели — суммарные лайки и share в историях выросли двузначно. Пользователи стали не только больше смотреть, но и чаще реагировать на истории. Для продукта это отличный результат: мы расширили понятие «интересная история» и получили больше вовлечённости.

Фактор автора, пользователя и их связи: фичи решают всё

Получив взрослую модель и кастомный лосс, мы обратились к следующему резерву роста — инженерии признаков. В ранжировании часто побеждает не более хитрая модель, а более информативные фичи. Мы это знали, поэтому параллельно с работой над таргетами и лоссами экспериментировали с новыми признаками. Тут наша философия была простой: если есть какой‑то сигнал о качестве историй, то мы должны его учесть. Но делать это аккуратно, чтобы не перегрузить модель и не ввести шум или смещение.

Начали мы с признаков автора. Логично, что некоторые авторы по своей природе более интересны аудитории, чем другие. Мы посчитали для каждого автора порядка 30 счётчиков: сколько раз за последние N дней его истории открывали, досматривали до конца, лайкали, отправляли друзьям, отвечали на них и так далее. Грубо говоря, замерили общую «успешность» автора в формате Stories. Эти абсолютные числа, правда, у автора с миллионом подписчиков будет много открытий просто из‑за охвата. Поэтому для каждого счётчика сделали ещё и относительную версию: например, сколько процентов от увидевших историю поставили лайк, сколько из просмотревших историю перешли к автору в профиль, и т. п. Такие CTR‑показатели лучше отражают качество контента автора, нормированное на его аудиторию. В итоге модель получила набор фич, позволяющих ей отличать топовых авторов от посредственных. Если автор стабильно набирает реакции от своей аудитории, то алгоритм охотнее покажет его кому‑то ещё.

Затем мы добавили фичи на пересечении пользователя и автора. Тут ключевой сигнал — история персонального взаимодействия. Мы ввели счётчики вида сколько раз конкретный пользователь открывал истории этого автора, лайкал их, комментировал и т. д. Если пользователь никогда раньше не смотрел истории определённого автора, то это один сценарий; а если он каждый день заглядывает — совершенно другой, такие авторы заслуживают быть выше в ленте для данного пользователя. Ещё один пласт — коллаборативные признаки. Мы построили матрицу «пользователь x автор», где ячейки — количество OpenViewer данного автора данным пользователем. На этой матрице обучили факторизационную модель и получили эмбеддинги для всех пользователей и авторов. В итоге для каждой пары user‑author у нас появились скрытые факторы, и можно было посчитать, например, скалярное произведение векторов между пользователем и автором в скрытом пространстве. Эти фичи помогают улавливать неявные связи: например, если пользователь любит котиков и есть автор, чьи истории про котиков (даже если пользователь их не лайкает, но смотрит), то в факторном пространстве они будут ближе.

И под конец мы обогатили модель контентными признаками. Хочется рекомендовать истории не только популярных авторов, но и новые интересы пользователя. Мы научили модель определять тематику историй (с помощью алгоритмов компьютерного зрения и обработки текста, но это отдельная история). У каждого автора вычислили профиль по темам: например, автор A: 40 % путешествия, 30 % еда, 20 % спорт, 10 % музыка. Для пользователя тоже можно собрать профиль интересов по темам историй, которые он смотрит. Далее всё просто: считаем сходство по темам между пользователем и автором и отдаём как фичу. Если сильного личного взаимодействия в прошлом не было, но по интересам они соответствуют, то велика вероятность, что пользователю понравятся истории этого автора. Ещё пример: для свежезарегистрированных пользователей или авторов, у которых мало статистики, такие контентные пересечения могут быть вообще единственным сигналом.

Добавляя блок за блоком, мы, конечно, тщательно всё проверяли A/B‑тестами. Многие фичи действительно давали плюс к финальной метрике: какие‑то — мизерный, какие‑то ощутимый. В совокупности богатый набор признаков прибавил около 20 % просмотров историй сверх предыдущего уровня.

Каждая группа фич внесла свой вклад, и вместе они существенно прокачали алгоритм. Особенно хорошо себя показали фичи по взаимодествиям пользователя с автором: личный опыт — сильнейший фактор. Также немалую пользу дали счётчики по автору: они подняли наверх некоторых недооценённых раньше авторов, чьи истории отлично заходят публике.

Конечно, не обошлось без ложных срабатываний и шуток. Одно время модель сильно полюбила авторов, у которых в историях много стикеров и масок — видимо, решив, что это залог успеха. Пришлось вручную добавлять признаки, отражающие степень заигрывания с визуальными эффектами, чтобы модель училась различать качественный контент и просто анимированные свистелки. В общем, добавляя фичи, мы ещё глубже поняли наших пользователей и авторов, иногда через забавные открытия.

Результаты

Постепенно наша система зрела. Вместо примитивного хронологического показа теперь работал сложный ML‑оркестр: сбор множества фич, расчёт скорингов градиентным бустингом с кастомным лоссом, да ещё и многокритериальным, ранжирование авторов — и всё это ради того, чтобы пользователям было интереснее листать истории.

Признаюсь, у нас было несколько идей‑фейлов, которые не дали вообще никакого эффекта. Например, мы пробовали хитро взвешивать таргет: учитывать позицию истории в ленте историй при расчёте метки (мол, если автор был высоко в списке кандидатов и его всё равно не открыли, то это более сильный негативный сигнал, чем если он был внизу). Звучало красиво, а A/B‑тест показал статистический ноль: ни хуже, ни лучше. Ещё один эксперимент: обучить отдельную модель на прокрутку ленты (типа предсказывать просто факт, что история попала на экран при пролистывании, без клика) и комбинировать её с основной моделью OpenViewer. Надеялись лучше учитывать пассивное потребление. Итог — снова ничего, только время потратили.

Конечно, запуск всех этих новшеств сопровождался множеством итераций и доработок. Не всё, что хорошо на бумаге, сходу приживается вживую. Были случаи, когда модель с великолепными offline‑метриками на истории в эксплуатации... давала просадку. Приходилось оперативно выключать и разбираться. Оказалось, некоторые фичи ухудшали качество свежих выдач, другие — слишком долго прогонялись в runtime. Мы выстроили чёткий процесс онлайн‑экспериментов: катим на долю трафика, замеряем изменения не только по просмотрам, но и по косвенным метрикам (удержание, время в приложении, негативные реакции). Если видим хоть малейший намёк на деградацию — откат, анализируем логи, правим. Такой цикл может повторяться десятки раз, прежде чем эксперимент пойдёт на весь трафик. Работа над рекомендациями — это бесконечное обучение, в том числе на собственных ошибках.

В итоге, сейчас рекомендации историй ВКонтакте — это зрелая, сложная, но интересная система. Мы продолжаем её развивать. В планах — ещё тоньше подстраиваться в реальном времени под поведение пользователя. Пока что модель обучается оффлайн на накопленных данных и периодически обновляется, но будущее явно за онлайн-подходами, возможно, с элементами reinforcement learning или быстрым переобучением на свежие сессии. Также присматриваемся к более глубоким нейросетевым моделям — вдруг удастся выжать дополнительные проценты, хотя текущий бустинг нас всем устраивает по скорости и качеству.

Если у вас остались вопросы или идеи — смело пишите в комментах. Спасибо, что дочитали до конца, и до встречи в новой истории (в прямом и переносном смысле). 

Комментарии (0)


  1. shaggyone
    16.09.2025 15:03

    Идея, от человека, который чуть монитор не заплевал, когда увидел посторонний контент в ленте: никогда не включать рекомендации пользователям, которые их отключили.

    Ну и, если делаете рекомендации, наверное не стоит ими заваливать фид? Я больше 10 лет подписан на одну группу по кино, в вашем фиде было постов 20 от левых групп по кино и штук 40 про науку плюс женские шмотки, т.к. одна моя хорошая подруга больше 10 лет как заделалась в стилисты. Я пролистываю сотню постов, чтобы узнать что том у моих друзей происходит.

    Замечу, я ролики на ютьюбе смотрю почти исключительно по его рекомендациям, а ваши алгоритмы, что старый, что новый выключаю при первой возможности. А заберёте возможность отключить, обойдусь без вас.


  1. MAXH0
    16.09.2025 15:03

    Честно - не подписан и не смотрю. НО видимо придётся. Т.к. педагогическая карьера подталкивает к созданию видео-контента, а нынешнее российское медиапространство жестко зачищено от альтернатив. Посмотрю что получится.