Когда читаешь о том, как работают с ML в крупных компаниях, всё выглядит логично: разбили пользователей на кластеры, провели A/B-тест, модель показала +5% к метрике — понесли в продакшен. 

У нас в Профи.ру более сложный продукт — двусторонний маркетплейс: живые заказы, которые через час уже будут неактуальны; специалисты, которые сегодня работают, а завтра в отгуле. Как тогда быть?

Привет, меня зовут Алексей, я руковожу ML-отделом. И в статье хочу рассказать о нашем особенном пути. А конкретно — про три проблемы, с которыми мы сталкиваемся каждый день.

Спойлер: до конца мы их пока не решили, но кое-что придумали.

Проблема 1. Классическим A/B-тестам нельзя верить из-за конкуренции между группами

Обнаружили так: взяли 10 000 специалистов (сантехники, электрики, мастера по ремонту), случайным образом поделили на контрольную (A) и тестовую (B) группы. Группе B показывали заказы с улучшенным алгоритмом ранжирования.

Сработало не совсем так, как мы ожидали:

  • У группы B выросла конверсия в сделку. 

  • У группы А она сильно упала, как и CTR.

В чём была проблема: 

  • В группе A специалисты видели заказы, отсортированные с помощью старого алгоритма.

  • Участники группы B видели те же самые заказы, но с улучшенным ранжированием.

В итоге специалисты из группы B быстрее получили релевантные предложения и начали массово их разбирать. 

И к тому времени, когда специалисты из группы A добрались до этих же заказов в своих лентах, слоты для откликов уже были заняты. Особенно ярко это проявилось в популярных категориях услуг, где несколько специалистов одновременно конкурируют за один заказ. 

Получается, что мы измерили не эффективность алгоритма, а конкуренцию специалистов за одни и те же сделки. И помогли одним за счёт других.

Как мы решаем проблему

После изучения опыта Uber, Lyft и DoorDash (они столкнулись с той же проблемой) мы внедрили switchback-подход:

Шаг 1. Кластеризация на независимые мини-рынки

Сначала мы идентифицируем естественно сложившиеся кластеры в нашем маркетплейсе.

Например:

  • Сантехники Москвы.

  • Репетиторы английского в Санкт-Петербурге.

  • Трамитадоры в Казани.

Шаг 2. Switchback по расписанию

Для каждого кластера расписываем чередование алгоритмов по дням:

Кластер "Сантехники Москвы, Бутово":
Пн: Алгоритм B | Вт: Алгоритм A | Ср: Алгоритм A | Чт: Алгоритм B ...

Кластер "Репетиторы английского онлайн":
Пн: Алгоритм A | Вт: Алгоритм B | Ср: Алгоритм B | Чт: Алгоритм A ...

Из-за того что мы тестим алгоритмы в рандомные дни, снижаем влияние сезонности на результат 

Шаг 3. Статистический анализ

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

def analyze_switchback(df):

   agg = (

       df.groupby(["cluster_id", "period_id", "assignment"])

         .agg({

             "impressions": "sum",

             "engagements": "sum",

             "deals": "sum",

             "unique_customers": "nunique",

             "unique_specialists": "nunique",

         })

         .reset_index()

   )




   def rdiv(a, b): return 0.0 if b == 0 else a / b

   agg["view_to_engage"] = [rdiv(e, i) for e, i in zip(agg.engagements, agg.impressions)]

   agg["engage_to_deal"] = [rdiv(d, e) for d, e in zip(agg.deals, agg.engagements)]

   agg["deals_per_customer"] = [rdiv(d, c) for d, c in zip(agg.deals, agg.unique_customers)]

   agg["deals_per_specialist"] = [rdiv(d, s) for d, s in zip(agg.deals, agg.unique_specialists)]




   metrics = ["deals", "view_to_engage", "engage_to_deal",

              "deals_per_customer", "deals_per_specialist"]

   report = {}




   for m in metrics:

       a, b = agg.loc[agg.assignment == "A", m], agg.loc[agg.assignment == "B", m]

       diff = b.mean() - a.mean()

       rel = None if a.mean() == 0 else b.mean() / a.mean() - 1




       diffs = [

           sub.loc[sub.assignment == "B", m].mean()

           - sub.loc[sub.assignment == "A", m].mean()

           for , sub in agg.groupby("clusterid")

           if sub.assignment.nunique() > 1

       ]




       k = len(diffs)

       md = sum(diffs) / k if k else 0

       se = (sum((x - md)  2 for x in diffs) / (k - 1))  0.5 / math.sqrt(k) if k > 1 else 0




       report[m] = dict(treat_mean=b.mean(), ctrl_mean=a.mean(),

                        abs_diff=diff, rel_lift=rel,

                        cluster_diff=md, se=se, clusters=k)




   flipped = (agg.groupby("cluster_id")["assignment"].nunique() > 1).mean()

   return dict(report=report, prop_clusters_switched=flipped)

Что это дало на практике

— Достоверность тестов выросла: корреляция между тестовыми и продакшен-метриками увеличилась на 17%.

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

  • нарастить количество сделок,

  • увеличить конверсию из просмотра заказа.

Что пока не получается

Межкластерная конкуренция 

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

Сезонные эффекты

Праздники и погода вносят погрешности, которые сложно отфильтровать, а иногда и распознать.

Проблема 2. 99% заказов живут меньше суток

Коллеги из односторонних маркетплейсов могут спросить: «Почему бы вам просто не запускать расчёт рекомендаций ночью для всех пользователей?»

И этот вопрос наглядно показывает: мы не одинаковые. 

В классическом интернет-магазине товар может лежать на цифровой полке месяцами. Его атрибуты неизменны: цена, категория, бренд. 

У нас по-другому. Среднее время жизни заказа измеряется часами, а часто — минутами. Цена и условия могут меняться в процессе диалога клиента со специалистами, которые уже откликнулись. 

Но главное, 99% заказов, актуальных сегодня, не существовали вчера. А завтра их уже не будет.

Метрика

Онлайн-магазин

Профи.ру

Единиц контента

~ 10K товаров

~ 10K активных заказов

Обновление каталога

5% в сутки

95% в сутки

Время жизни единицы

Месяцы/годы

2,5 часа (медиана)

Предсказуемость спроса

Высокая

Низкая

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

Заказ создан в 15:00. 

А к 18:00 он уже либо выполнен, либо потерял актуальность: пользователь успел купить новый смеситель или позвать сантехника из управляющей компании.

Именно поэтому наша задача — быстро показать заказ нужным специалистам, чтобы случилась сделка.

Почему не работают классические методы

Коллаборативная фильтрация требует истории взаимодействий. Не подходит, так как мы мало знаем о клиентах.

Матричная факторизация зависит от стабильных сущностей. А у нас состав заказов обновляется практически полностью каждые 24 часа. Как и пул клиентов.

Как мы решаем проблему

Наш подход — real-time-архитектура. Она принимает решение за 200–500 миллисекунд от момента создания заявки до выдачи ранжированного списка заказов.

Проиллюстрируем тезис кодом. Он, конечно, не из прода, но всё равно показательный. 

def rank_orders_for_specialist(specialist_id, filters):

   # запрашиваем предпосчитанные фичи специалиста из офлайн-хранилища

   spec_feats = offline_feature_store.get_specialist_features(specialist_id)




   # забираем из онлайн-хранилища заказы, удовлетворяющие фильтрам

   # (e.g. в радиусе 5 км от специалиста)

   order_feats= online_feature_store.query_orders(filters=filters)




   # объединяем фичи специалиста и заказа в один датафрейм и кодируем для модели

   spec_df = spec_feats.repeat(len(order_feats)).reset_index(drop=True)

   X = pd.concat([order_feats, spec_df, axis=1)

   X_enc = encode_for_model(X)




   # предсказываем вероятность того, что специалист заинтересуется заказом

   p = model.predict_proba(X_enc)[:, 1]




   # сортируем заказы по вероятности и возвращаем топ

   orders["score"] = p

   ranked = orders.sort_values("score", ascending=False)




   return {"specialist_id": specialist_id,

           "ranked_orders": ranked[["order_id", "score"]]}

Что это дало на практике

— Пользователи получают быстрые и актуальные отклики.

— Специалисты видят релевантные заказы в тот момент, когда они действительно могут за них побороться.

— Метрики конверсий выросли — пользователи чаще получают специалистов, а заказы закрываются быстрее.

Что пока не получается

Не можем учесть всё: предугадать человеческий и природный факторы. 

Пример: рекомендуем сантехника Игоря как хороший вариант для клиента, создавшего задачу. А на деле оказывается, что у специалиста уже нет свободного времени на неделе благодаря занятости через сарафанное радио. 

В новых услугах и нишах тоже тяжело, так как не хватает вводных для расчётов.

Пример: мы не знаем заранее, насколько специалисты (скажем, тренеры по хоббихорсингу) мобильны и будут ли они готовы ехать на другой конец Москвы. 

Проблема 3. Мы не контролируем сделку целиком

На Профи.ру уже сейчас десятки тысяч разных услуг, и мы постоянно пополняем этот список, добавляем новые категории, если на них есть спрос. 

Но у такого разнообразия есть и обратная сторона:

  • мы не можем досконально промоделировать каждую новую услугу и описать все детали;

  • многие вводные задачи всплывают уже после создания заказа — в чате между клиентом и специалистом.

Например, пользователю нужно погулять не просто с одной собакой, а с тремя разными питомцами. При этом у каждого своя траектория, арсенал для прогулки (игрушки, лакомства, вода) и даже время выгула. 

Как в итоге может выглядеть пайплайн такой сделки:

  • Клиент формирует приблизительный запрос.

  • Специалист, в свою очередь, указывает приблизительную цену.

  • В чате оказывается, что реальных условий намного больше и стоимость нужно менять.

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

Есть ещё вариант, когда специалист после беседы понимает, что это не его профиль (например, нужен не просто догситтер, а ещё и кинолог), и поэтому отказывается от задачи: не срослось. 

Что это значит для нас, ML-разработчиков:

  • Не всегда знаем, какая цена в итоге была согласована.

  • Не можем сказать точно, была ли она оправданной относительно сложности заказа.

  • Не понимаем настоящую причину отказа, если он был, и не можем использовать эти данные для улучшения алгоритма.

Как мы решаем проблему

Мы понимаем, что проследить весь трек сделки и учесть все вводные было бы огромной нагрузкой на специалистов нашей службы поддержки. Поэтому сознательно концентрируемся только на том, что на 100% в нашей власти:

  • на дизайне рынка;

  • алгоритмах мэтчинга.

Что конкретно делаем:

1. Фокусируемся на том, кому и какие заказы показать:

  • кто первым увидит новый заказ;

  • какие заказы окажутся выше в ленте у специалиста;

  • как учитывать профиль специалиста, опыт, локацию и другие признаки релевантности.

2. Используем как показатель успеха только те события, которые точно можем отследить на платформе:

  • факт отклика;

  • факт начала общения в чате;

  • отказ специалиста от заказа.

3. Концентрируемся не на стоимости задач, а на качестве мэтчинга:

  • насколько задача подходит конкретному специалисту;

  • насколько высока вероятность, что после переписки они договорятся.

Что это дало на практике

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

  • отклик → 

  • переписка → 

  • отказ / продолжение общения. 

Что пока не получается

Свести сделку к одному правильному ответу для модели

Даже если мы видим часть переписки, у нас нет понимания, что эта задача за X рублей — ок, а она же за Y — дороговато. Есть только цепочка событий и косвенные сигналы, которые пока что сложно превратить в ground truth.

Развести причины отказа на уровне данных

Причины «специалисту не подходит профиль» и «специалист в этот день не хочет выезжать из своего района, потому что сам ждёт мастера по ремонту» выглядят для нас одинаково, так как мы видим только сам факт отказа. Если факторы, из-за которых специалист не взял заказ, остались в личной переписке, мы не сможем их увидеть и обработать.


Интересно послушать о проблемах, с которыми вы сталкиваетесь в вашем продукте. Может, было что-то похожее на наши? И как, кстати, решаете задачу найма ML-специалистов, которые должны работать со специфичным продуктом?

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