Продуктовые истории. Как из подручных средств слепить ранжирование, которое даёт +20% выручки?
Этим постом хочу открыть серию рассказов, в которой поделюсь своим практическими опытом. Пишу, в первую очередь, для коллег-аналитиков, во-вторую — для всего околопродуктового люда. Первым — пища для ума и пример того, чем занимается аналитик сеньорного уровня, вторым — просто продуктовые байки. Во всяких там линкединах люди много пишут о своих достижениях, но сильно меньше рассказывают, как они к ним пришли. Вот про это и поговорим!
Проблема и контекст
Я работаю в туристическом сервисе-агрегаторе, одно из направлений нашего сервиса — отели и квартиры. В числе прочих сценариев есть традиционный поиск. Это когда юзер выбирает город, даты, состав гостей и получает каким-то образом отсортированный список (поисковую выдачу). И из A/B-тестов, и из данных мы уже знаем, что пользователи в этом сценарии не слишком-то глубоко выдачу листают, а выбирают что-то с верхних позиций. А уж на самую верхнюю карточку приходится львиная доля всех кликов, да и заказов тоже. Отлично, но как это знание правильно монетизировать? Очевидно, что на самом верху должны находится отели, которые нам, как сервису, приносят наибольшую прибыль! Собственно, что-то такое мои коллеги и пытались сделать до того, как я плотно стал заниматься темой ранжирования. Для понимания — у нас есть несколько партнёров, через которых можем продавать отель юзеру (какие-то отели есть только у одного партнёра, какие-то — сразу у нескольких, в таком случае показываем юзеру предложение от самого выгодного), отличаются они между собой как раз комиссией которую мы получим с продажи. Решением было как раз размещать на самом верху все отели от партнёра с максимальной комиссией, а потом уже все остальные. Стоит добавить, что у нас что внутри приоритетного (от любимого партнёра) сегменте, что внутри всех остальных не было какой-то супер-крутой системы ранжирования — ориентировались мы, в основном, на цену, чем лучше она, по мнению модели ранжирования, совпадала с «оптимальной», тем выше была позиция отеля. Звёздность, отзывы, наполнение отеля учитывались, но очень косвенно. В итоге мы имели выдачу, которая на самом верху показывала какие-то выгодные нам отели, а далее - после 20-50-100 позиции(когда выгодные оферы заканчивались), пыталась показать все остальные. Даже на чистой интуиции можно понять, что оптимально такое работать не будет, так как на самом верху юзеру будут видеть точно не самый классный отель, а тот, с которого мы больше заработаем. Причём заработок этот чистое теоретический, ведь на практике жесткая привязка к цене приводила к тому, что сервис ранжирования считал, что хостелы около МКАД-а и сомнительные отели с двумя звёздами в промзонах — это именно то, что нужно нашей аудитории.
Ограничения и попытки решения
Для решения классической задачи ранжировоания нужно два элемента: во-первых, информация о том, что показали (желательно — в каком порядке), во вторых — как с показанным провзаимодействали — клики, покупки и т.п. Кидаешь это всё в машинное обучение, замешав с другими фичами, типа параметров поиска, на выходе получаешь информацию о том, кто на какой позиции должен быть. Вот только у нас была проблема — информация о показах отсутствовала в принципе (были только наджежды, что через год-другой она таки появится), информация о кликах-заказах была, но было не очень понятно, как её использовать в классическом решении без показов. Первый прорыв у нас произошёл, когда у нас появилась техническая возможность организовать хранение информации об отелях, так называемый feature store. Собтсвенно, такое хранилище нам было бы нужно и для классического подхода (где у нас есть и показы, и клики, и заказы), но на тот момент мы туда могли складывать информацию о тех отелях, с какими юзеры уже повзаимодействали, кликали в выдаче или покупали. Первым нашим экспериментом было просто советское «А давайте просто по числу заказов за год отели отсортируем, какой больше покупали, тот и будет выше». Сделали, посмотрели… И даже такой прямолинейный эксперимент принёс нам чуть ли не двухзначный рост конверсий! Ну что, не думаем, катим в прод? Выкатили, но без ложки дёгтя не обошлось — в заказах мы выросли, в оборотах тоже, но вот выручка наша практически не изменилась, даже (незначимо, но всё же) стала меньше — продажи ушли в менее выгодных нам партнёров, с которых мы могли получать до 60% меньше комиссии с заказа. Это было основной проблемой, а второстепенная звучала так — «А точно ли наша статистика продаж отражает то, что реально хотят юзеры? А как в такой схеме незнакомый (без заказов, но объективно “хороший”) пользователям отель окажется наверху выдачи?”. Вот примерно на этом этапе я и включился в активное развитие нашего ранжирования (о том, как оказался в этой роли, можно почитать вот тут).
Результаты предыдущих экспериментов и данные давали мне следующие вводные:
— Отели наверху выдачи имеют большое преимущество, даже не самые релевантные получают трафик и продажи.
— Продажа через выгодного партнёра ощутимо выгоднее для нас, два заказа через выгодного по деньгам для нас могут быть интереснее, чем три через остальных.
— Наша статистика продаж слабо отражает реальные потребности юзеров, так как долго время мы им показывали (а они покупали) не то, что интересно клиентам, а то, что выгодно нам.
— Появляются новые отели, а “старые” могут стать хуже — задрать цены, убрать номера из продажи, да просто обветшать и устареть.
— Текущая версия ранжирования показывает наверху отель с наибольшим числом продаж за год; топ почти не меняется.
— Информации о показах у нас нет, но есть возможность использовать информацию о заказах + обновлять её для ранжирования с некоторой периодичностью.
Что получилось
Наше ранжирование на тот момент работало примерно на таком SQL запросе:
SELECT hotel_id, count() as rating FROM orders WHERE order_date >= today() - 365 AND successful_order GROUP BY hotel_id;
Запрос исполнялся раз в сутки и обновлял информацию в feature_store-е. Собственно, нужно было придумать что-то более интересное, так распорядится имеющимися данными, чтобы учесть все указанные выше нюансы + придать нашей выдаче чуть больше. Мне не давали покоя следующие идеи:
— Не все отели от приоритетного партнёра одинаково полезны. Давать преимущество всем отелям от него — тупо, даже несмотря на сладкую повышенную комиссию, мы наглядно увидели проблему такого прохода в результатах A/B-теста. Но точно среди выгодных отелей есть какое-то число и тех, что интересны и релевантны нашей аудитории. Как именно их найти и пропихнуть наверх и держать там максимально долго?
— Высокую позицию заслуживает не тот объект, у которого много продаж, а тот, в который продажи идут ПРЯМО СЕЙЧАС. Целевая картина: Отель продается = остаётся в топе. Продажи упали — вылетает и уступает место следующему перспективному.
— Отели из верха выдачи будут получать продажи не только за то, что они хорошие, но просто за сам факт нахождение в топ-3. Объект наверху -> идут продажи -> остаётся наверху. Что делать с такими “царями горы”?
Вышеперечисленные идеи удалось скомпоновать в новый SQL-ник, который выглядел примерно так:
WITH prep as (
/*этап подготовки заказов*/
SELECT hotel_id,
(
30 / (recency + 1) /* recency - разница в днях между "сегодня" и временем заказа */
* multiIf(provider_id = ‘fav_partner’, 3,
provider_id = ‘not_so_fav_partner', 1.5,
1) /* provider_id - айдишник партнёра */
* multiIf(pos = 1, 0.1, pos = 2, 0.25, pos IS NULL, 1.25, 1) /*pos - позиция, с которой был совершён заказ */
* multiIf(stars >= 4, 1.1, stars <= 2, 0.5, 1) /* личная вкусовщина в виде небольшого продвижения повыше отелей с 4+ звёздами и штраф объектам без звёзд и клоповникам */
) AS weight
FROM orders
WHERE order_date >= today() - 183 /*сократили окно броней до полугода */
AND successful_order
)
/* складываем веса и учитываем рейтинг/отзывы */
SELECT hotel_id, SUM(weight) * (pow(ifNull(adjusted_rating, 7.5), 2) / 64) as rating /*adjusted_rating - рейтинг с поправкой на малое число отзывов */
FROM prep
GROUP BY hotel_idОт прямолинейной логики «больше продаж = выше скор у отеля» перешли к более сложному расчёту. Основные нюансы:
multiIf(provider_id = ‘fav_partner’, 4,
provider_id = ‘not_so_fav_partner', 1.5,
1) Эта строка учитывает партнёра, через которого провели продажу. Одна через любимого партнёра считается как три через обычного.
multiIf(pos = 1, 0.1, pos = 2, 0.25, pos IS NULL, 1.25, 1)
Тут мы штрафуем за покупки с 1 и 2 позиции, пытаемся сделать их не такими значимым и компенсировать эффект “царя горы”. А заказам с карты или когда отель нашли напрямую по называнию, наоборот, даём небольшое увеличение веса — считаем хорошим сигналом, когда отель ищут специально.
30 / (recency + 1)
Моё люимое. Свежие продажи имеют намного более высокий вес, причём уменьшается он нелинейно. Заказ “вчера” имеет намного более сильный сигнал, чем точно такой же заказ неделю назад. Сравните — вчерашний заказ будет умножен на 30/(1+1) = 15, а недельный - только на 30/(7+1) = 3,75. После 30 дней разница между днями сокращается почти линейно, а вклада они в общий рейтинг начинают вносить совсем немного. Получившие в прошлом много заказов всё ещё будут иметь преимущество, но оно будет не таким явным и устойчивым, быстрорастущий новичок сможет его перебить
* (pow(ifNull(adjusted_rating, 7.5), 2) / 64)
Вишенка на торте, учитываем наличие отзывов у объекта и их средний рейтинг. Тут тоже действуем не в лоб и учитываем и число отзывов, и оценку. Во-первых, штраф/буст нелинейный, чем дальше от “среднего”, тем сильнее наказывается и продвигается объект. Объект с со средним рейтингом в 6 получит штраф рейтинга на 36/64 = 0,563 (что ощутимей линейного 6/8 = 0.75 ). Во-вторых, сглаживаем эту логику для объектов с 1-2 отзывами, чтобы случайная единица или десятка в одном единственном отзыве не давала мощного штрафа или такого же можного буста. Это делается в adjusted_rating, там мы к реальному число отзывов прибавляем еще несколько виртуальных, со средним баллом в 7.5. На отели с большим числом оценок это никак не влияет, а одну реальную 1 превратит в 5.33. А единственная 10 станет 8.3. Кстати, эта идея пришла мне на одной из менторских сессий, поэтому прорекламирую свой пост про то, как я стал ментором и зачем вообще этим занимаюсь.
Результаты и грабли
Полученную модель мы прогнали через а/б тест и получили и небольшой рост конверсий, и значимый рост выручки с заказа. Ну и конечно, выдача стала быстро подстраиваться под изменения спроса. Всё хорошо, ранжирование пройдено, можно переключаться на другие задачи? Как бы не так! За скобками осталось как минимум проблема того, что мы всё ещё ориентируемся на нашу имеющуюся статистику продаж, хоть и исправляем её заметно быстрее. Об этом расскажу в следующих постах.
А этот пост закончу ложкой дёгтя и подсвечу мину, которую мы сами себе заложили, и на которую наступали в следующих А/Б тестах. Вдвойне обидно, что потенциальную проблему видели сразу, но если в первом тесте она нам скорее помогала, ну или хотя бы не мешала, то потому мы с ней намучились. Речь о том, что мы поленились сделать хоть какой-то механизм изоляции агрегатов ( так называются у нас результаты SQL запросов, которые приводил выше). В итоге, во время а/б тестов у нас продажи тестовой и контрольной группы влияли друг на друга. Если в нашем первом тесте этим можно было пренебречь — контроль слабо менялся из-за свежих продаж, а для тестового варианта инфа из контроля скорее была полезна, то потом мы на этом обжигались. В следующих тестах ловили эффект «подсматривания» — получившаяся версия уж очень ловко подстраивалась под информацию о свежих продажах. В том числе и тогда, когда мы её сравнивали с более новыми алгоритмами. Если бы можно было вернутся назад и что-то сделать иначе, то я бы сразу заложил изоляцию заказов для теста и контроля!
Ну а у меня на сегодня всё, удачных вам а/б тестов, роста метрик и зарплат!