
Когда читаешь о том, как работают с 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 по расписанию
Для каждого кластера расписываем чередование алгоритмов по дням:
Кластер "Сантехники Москвы, Бутово": |
Из-за того что мы тестим алгоритмы в рандомные дни, снижаем влияние сезонности на результат
Шаг 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-специалистов, которые должны работать со специфичным продуктом?