
Всем привет! Меня зовут Воронцов Александр, я занимаюсь машинным обучением и анализом данных в Ситидрайве. В этой статье расскажу, как мы развивали систему динамического ценообразования: что это такое на практике, почему это так важно для каршеринга и с какими сложностями мы столкнулись.
Введение: почему в каршеринге не обойтись без «динамического ценника»?
Под динамическим ценообразованием в каршеринге понимают механизм, при котором базовая стоимость аренды (поминутная, почасовая и т. д.) зависит от плавающего коэффициента (surge), который в свою очередь зависит от спроса, времени суток, локации и многих других факторов.
Почему это важно?
Мы хотим, чтобы машины Ситидрайва всегда были в пешей доступности для каршероводов даже в высокий спрос.
Сервису нужно, чтобы автомобили не простаивали часами.
На первый взгляд, всё похоже на классический surge-прайсинг из мира такси. Но у каршеринга хватает своих особенностей: как учесть, к примеру, что одна машина только после сервисного обслуживания и выглядит как новая, а у другой, просторный салон, детское кресло и её всегда разбирают быстрее обычного?
Мы прошли долгий путь, прежде чем придумать действительно работающее решение. Ниже я поделюсь основными проблемами, экспериментами, удачными находками и — не менее важно — теми идеями, которые так и не взлетели.
Подход №1: «Быстро, дёшево и сердито»
Первые мысли
С самого начала мы стремились создать систему, которая бы учитывала изменения спроса на автомобили и позволяла своевременно корректировать цены. Это помогает нам предлагать пользователям выгодные условия аренды и гарантировать доступность нужных машин в нужное время. Но на практике оказалось, что «быстро собрать ML-модель и пустить её в прод» — это иллюзия.
Во-первых, появляется инфраструктурная головная боль: модель должна выдавать актуальный коэффициент в реальном времени (с учётом топлива, координат, количества пользователей на карте и т. п.). Значит, нужен отдельный сервис для скоринга, связка с брокерами сообщений (Kafka или аналогами), база для логов — всё это надо грамотно поддерживать.
Во-вторых, непонятно, что именно предсказывать. Сразу коэффициент? Или спрос? Или время простоя? На поиск правильной метрики и постановку задачи уходит уйма времени.
В-третьих, изначально мы хотели хранить коэффициент в формате (машина, время) → коэффициент. Но у машины куча переменных: она только что уехала в соседний район или резко сменила статус из-за того, что ее забрали на тех обслуживание. Постоянно обновлять коэффициент под каждую машину — слишком сложно для «быстрого решения».
В итоге мы согласились, что на старте от онлайнового ML придётся отказаться. Чтобы как можно быстрее предложить пользователям гибкую систему ценообразования, мы начали с простой и эффективной базовой модели. Далее расскажем, как она работает и чем она полезна.
«Простая табличка» и переход к локациям
Чтобы запустить базовую «динамику», мы сделали простую схему:
Разработали коэффициенты, которые привязаны к определённому времени и зоне.
Когда машина паркуется, сервис проверяет, в какой зоне она стоит, и берёт «готовый» коэффициент из таблицы.
Почему мы ушли от идеи машина → коэффициент к локации → коэффициент?
Автомобили в течение дня могут перемещаться между районами с разным уровнем спроса — например, из центра, где машины востребованы, в более спокойные зоны. Чтобы упростить процесс ценообразования, мы ориентируемся не на каждую конкретную машину, а на ситуацию в районе. Если, например, в каком-то месте по утрам стабильно высокий спрос, система это учитывает и применяет корректировку. Такой подход помогает нам сделать так, чтобы автомобили были под рукой именно там и тогда, когда они особенно нужны.
«Hex-based»: почему так удобно
Мы разбили город на шестиугольники (гексагоны), популярный формат в геоанализе:
Шестиугольниками легко покрыть всю карту без дыр и двойных наложений.
В каждом гексе можно собирать и агрегировать статистику (сколько поездок случалось в этот час и т. д.).
Что в итоге получилось
Плюсы. Такое решение легко объяснить и внедрить. Чтобы учитывать новые машины, достаточно взять уже посчитанный коэффициент для гексагона и текущего часа. Широкая инфраструктура вроде очередей Kafka и сервисов для скоринга пока не нужна.
Минусы. Система не реагирует на внезапные события «здесь и сейчас».К тому же обновлять таблицу с коэффициентами нужно вручную, а мы стремились к автоматизации.
Однако на старте такой метод отлично «закрыл» наши базовые потребности: цены стали меняться в зависимости от места и времени, пользователи оценили доступность автомобилей, утилизация машин выросла, а команда получила время (и мотивацию) для дальнейших экспериментов с более сложными моделями.
Подход №2: «Окей, давайте учитывать онлайн»
После первого шага стало понятно, что реальные события «здесь и сейчас» (пробки на дорогах, резкий всплеск спроса в определённом районе) могут радикально влиять на загрузку парка. Система, основанная лишь на истории, таких вещей не замечает.
Задействуем «онлайн-факторы»
Так возникла идея задействовать онлайн-факторы. Прежде всего мы обратили внимание на:
Активных пользователей в зоне.
Общее количество онлайн-заказов за недавний промежуток.
Количество доступных авто.
Как «скрестить» с исходной hex-моделью?
Мы не стали отказываться от уже работающей «hex-таблицы», которая опирается на исторические данные (время суток, район). Вместо этого решили «смешивать» оба подхода:
coefficient = w1 × coefficient_hex + w2 × coefficient_online
coefficient_hex (из первого подхода) учитывает, что обычно происходит в этом месте в это время.
coefficient_online показывает, что происходит прямо сейчас.
Как понять, что система стала лучше
Когда мы внедрили первую версию (одна лишь hex-модель), эффект был очевиден: простои машин сократились, а утилизация выросла. Тут всё и так ясно, никаких тонких замеров не потребовалось.
Но теперь, когда мы прибавили онлайн-факторы, требовалось подтвердить, что новая схема действительно показывает эффективность, а не создаёт просто «шум». Провести геосплит (деление города на две зоны) не получилось: с разными ценами в разных кварталах поведение пользователей искажается. Случайное деление по пользователям тоже не годилось — мы не могли одной и той же машине в один момент предлагать несколько цен. Мы всех каршероводов любим одинаково.
Мы выбрали «покадровый» метод:
В один час (или другой интервал) работала старая модель,
В следующий час — новая,
Потом снова старая и так далее.
Собирая метрики (прибыль, простой, конверсию) в эти «старые» и «новые» интервалы, мы смогли оценить, насколько онлайн-признаки реально влияют на результат. Разумеется, пришлось осторожно учитывать время суток, выходные дни и прочие внешние факторы.
Итоги второго подхода
Плюсы. Ценообразование стало чувствительнее к реальной обстановке— если в каком-то районе неожиданно вырос спрос или резко снизилось число доступных машин, цена подстраивается тут же. Смешение исторических и онлайн-подходов даёт гибкость: достаточно отрегулировать веса, чтобы изменить «агрессивность» реакции.
Минусы. По-прежнему один гекс = один коэффициент для всех машин. Проводить эксперименты стало сложнее, ведь приходится аккуратно планировать «включение» и «выключение» нового механизма по времени и следить, чтобы внешний шум (например, вечерние часы или праздники) не испортил выводы.
Так мы получили более гибкую схему, которая уже учитывает текущее состояние рынка, но до тонкого учёта особенностей конкретной машины ещё далеко. К этому мы и переходим в третьем подходе.
Подход №3: «Car-based», или персональный коэффициент для каждой машины
Зачем уходить от гексов?
Идея проста: у каждой машины может быть свой собственный коэффициент. Ведь машины отличаются по модели, состоянию, пробегу, цвету, уровню топлива и многим другим факторам. Всё это влияет на то, насколько быстро автомобиль возьмут в аренду — а значит, и на оптимальную цену.
Однако «детализация до каждой машины» заметно усложняет жизнь. При гексах мы просто брали (локация, время) → коэффициент, а теперь нужно пересчитывать модель под каждую машину, причём иногда в реальном времени.
«В режиме реального времени» = боль
Да, часть расчётов мы можем делать «пакетно» раз в час (или в другой промежуток). Но что, если машина только что закончила аренду и припарковалась? В течение нескольких секунд информация о ней должна попасть в приложение. Мы хотим сразу показать цену, уже учтя её состояние, топливо, локацию и т. д.
Для этого придётся:
Отловить событие «машина припарковалась» через Kafka.
Собрать признаки: где стоит авто, сколько в баке топлива и так далее.
Прогнать всё через ML модель.
Передать результат в сервис.
Любые задержки — и пользователь подумает, что приложение «тупит», а мы хотим оказывать быстрый и удобный сервис. Значит, нужен отдельный микросервис для скоринга, оперативный доступ к БД и вычислительные мощности. Всё это дорого в поддержке, но даёт конкурентное преимущество.
Сбор датасета
Чтобы «онлайн-модель» умела предсказывать время простоя машины, нужен большой исторический датасет: в какие моменты машина стояла без аренды, когда её в итоге взяли и какой был спрос в этот момент.
Фиксируем момент T=13:28, когда машина «А» свободна.
Смотрим, когда её возьмут на самом деле (допустим, в 13:57). Получаем «время простоя» = 29 минут.
Сохраняем все признаки на момент T.
Повторяем для множества «машиномоментов» (сотен тысяч или миллионов).
Итог: (признаки) → (время простоя) — классическая регрессия, где модель учится прогнозировать, за сколько минут машину забронируют. Если модель предсказывает, что машина будет стоять долго, стоит уценить аренду, чтобы привлечь пользователя.
Само собой, при «реальном» запросе нужно за доли секунды собрать эти признаки, передать в модель и получить результат. Инфраструктурно это непросто, зато даёт индивидуальную динамику по каждой машине.
Ресёрчи признаков
При построении «car-based» мы провели ряд исследований. Некоторые дали прирост в качестве, другие — нет, но все были важны для понимания итоговой модели.
1. Анализ временных рядов
Мы хотели проверить, есть ли у машин и у всех каршероводов стабильные паттерны активности. Может ли одна машина стабильно делать по 10 поездок в день, а другая регулярно «застаивается»? От чего это зависит?
Для экспериментов взяли библиотеку Etna, чтобы строить и прогнозировать временные ряды.
Предсказание активности машин
Сначала мы предположили, что частота поездок может подчиняться хоть какой-то сезонности или тренду. Однако при детальном рассмотрении обнаружилось, что факторы вроде ремонта (машина увезена в сервис), резких перемещений из одного района в другой и других «шумовых» событий полностью ломают любые регулярные паттерны. Модель попросту не улавливает такие скачки, и её точность падает.
Предсказание активности пользователей
Пользователи еще более «хаотичны»: пользователи могут пропадать на месяц и потом внезапно начинают пользоваться каршерингом ежедневно. Без постоянных дообучений (каждую неделю или даже чаще), модель не успевает адаптироваться к таким резким сменам поведения.
В результате мы пришли к выводу, что временные ряды не дают заметного прироста в текущих условиях:
Слишком много шума и аномалий.
Регулярное дообучение и сложное оповещение о «выбросах» требуют серьёзной инфраструктуры.
Прирост качества либо нулевой, либо даже отрицательный (модель переобучается на нестабильных паттернах).
Поэтому мы не стали включать временные ряды в финальную модель, сосредоточившись на признаках, которые надёжнее работают в онлайне без постоянной перенастройки.
2. Разделение городов
Одна из «простых» идей, которая сработала: обучить отдельную модель для каждого города. Несмотря на то, что алгоритмы оставались теми же, локальные модели оказались точнее единой «глобальной». Разные города имеют разные паттерны спроса и поведения водителей.
3. Синтетические признаки
Иногда самый эффективный путь — скрестить уже имеющиеся фичи. Классический пример: время суток и количество топлива.
Наивный Байес: пример графика
Мы обучили наивный Байес с двумя признаками: (x) — объём топлива, (y) — время суток. Цель: классифицировать, «уедет ли машина в ближайший час». Такой упрощённый подход (вместо регрессии) мы выбрали, чтобы быстро проверить, насколько эти два фактора влияют на вероятность скорого отъезда, не усложняя модель подсчётом точного времени простоя.Ниже — схема, где зелёные точки означают «уехала», красные — «осталась»:

Видим, что при высоком баке и в первой половине дня машины бронируют охотнее.
Полиномиальная фича
Чтобы учесть эту зависимость в более сложных моделях, мы ввели «полиномиальную» комбинацию:

где n — небольшая степень (например, 3), а a_ij — коэффициенты, которые мы получаем при обучении полинома. Фактически это полиномиальная комбинация двух исходных признаков: часа и объёма топлива. Оказалось, что простой наивный Байес только на этой фиче предсказывал «уедет/не уедет» всего на ~15% хуже «тяжёлой» модели из 40 признаков.
Итог
Мы продолжили экспериментировать с другими комбинациями (например, день недели × средняя скорость), оставляя лишь те, что давали реальную пользу. Итоговые «синтетические фичи» в нашей финальной модели заметно повысили точность предсказания времени простоя, не перегрузив систему лишними вычислениями.
4. Интерполирующие многочлены
Мы также опробовали аппроксимацию признаков с помощью полиномиальных кривых. Пример: берём зависимость «среднее время простоя» от «времени суток» — в исходном виде она шумная. Но если построить интерполирующий многочлен, то получаем более сглаженную функцию:

Так мы «сглаживаем» сырые данные и получаем более ровный признак, который лучше воспринимается моделью. В итоге прирост был не гигантским, но ощутимым: модель меньше «дергается» на случайных выбросах, а обобщающая способность возрастает.
5. Полиномиальное ядро второго порядка для всех признаков
Мы также попытались «машинно» сгенерировать все пары и квадраты признаков (до 2-й степени). Для этого взяли исходные признаки и сгенерировали все пары и квадраты до выбранной степени. Получили новые признаки.
Идея была простой: пусть модель сама выберет нужные комбинации из этого большого пространства. Но:
Число признаков выросло лавинообразно, что усложняло обучение.
Результат был отрицательным — либо переобучение, либо модель не успевала «вчитаться» во все сгенерированные фичи.
Мы отказались от «глобального» полиномиального ядра, оставив ручное создание узкого круга комбинаций, где точно видим связь.
Архитектура финальной модели
Получив итоговый датасет с самыми полезными фичами, мы перешли к финальной сборке. Задача прежняя: предсказать, через сколько минут машину арендуют. Но мы добавили особую «каскадную» схему:
Набор бинарных классификаторов (60, 120, 180, 240, 300 минут): Каждая модель выдаёт вероятность pX, что машину заберут раньше, чем X минут.
Мета-уровень: мы дополняем исходные признаки этими вероятностями (p60, p120, p180, p240, p300).
Финальный регрессор (мы остановились на CatBoost), который уже на обогащённом датасете предсказывает точное время простоя.
Зачем так усложнять?
Разные горизонты дают разные «подсказки». Классификатор на 300 минут может быть стабильнее, чем на 60, который более «нервный».
Комбинация из нескольких вероятностей даёт регрессору более богатую информацию, чем если бы он сразу учился по одному таргету «число минут».
Это добавляет ещё один уровень кода и ресурсов в онлайне — ведь для одного запроса нужно вызвать сразу несколько моделей. Но результат точнее, чем простая регрессия.
Перевод предсказанного времени простоя в итоговый коэффициент
Допустим, финальная модель говорит: «Машину заберут через 5 минут». Как из этого сделать коэффициент surge? Если предсказано, что машину возьмут практически сразу, значит спрос сейчас будет увеличиваться. Если машина обречена стоять 300 минут, лучше снизить стоимость, чтобы подтолкнуть спрос.
Тест прайс-эластичности
Насколько именно изменять цену, заранее не скажет ни одна модель. Для этого потребуется анализ истории. На исторических данных встречаются естественные колебания цен, откуда можно вывести примерный процент падения/роста спроса.
По результатам строим «кривую эластичности» (или даже набор кривых под разные классы авто или районы). Именно она позволяет принять решение: насколько снижать коэффициент surge, если модель сказала “машина уедет только через 150 минут”.
Формирование итогового распределения
Часто нужно не просто «line-to-line» мапить время простоя в коэффициент, а управлять общим распределением surge. Покажу на примере:
Исходное распределение
Сначала смотрим, как вообще распределены предсказанные времена простоя на трейн/тест-сетах.

Желаемое распределение
Затем, исходя из теста прайс-эластичности и бизнес-требований, определяем «какой формы» нам нужен итоговый surge:

Результат маппинга
Мы делим исходное распределение на процентили (например, 300 «корзин») и каждой корзине назначаем surge из желаемого спектра. На выходе получаем:

Это даёт гибкое управление ценой без переобучения модели, а лишь с помощью «целевого маппинга»
Итоги «car-based»: игра стоит свеч
После двухнедельного теста мы увидели, что индивидуальная модель действительно даёт меньше простоя (пользователи довольны ценой и охотнее бронируют авто), чем даже «hex + онлайн» (второй подход). Но и внедрять её — куда сложнее:
Инфраструктура: моментальный скоринг при каждом паркинге, отдельный сервис, моментальный доступ к всем базам (данные должны собираться меньше чем за секунду), что в больших масштабах нетривиально.
Тонкость экспериментов: теперь каждое изменение признаков или алгоритмов требует аккуратного теста, где мы чередуем (во времени) новую и старую систему и очень внимательно анализируем результаты. Также иногда нужно проводить тесты прайс-эластичности.
Однако это разумная плата за доступность машин для наших пользователей и удобство сервиса. И здесь уже появляется реальное полевое использование ML, где мы наглядно видим, что в час-пик можно найти авто и уехать, а в низкий спрос цены более низкие.
Подход №4: Математика и оптимизация (где мы сейчас)
Когда мы перешли к car-based-подходу (каждая машина получает «персональный» коэффициент), следующим шагом нужно было понять, как выбрать оптимальный surge. Нужно поддерживать некоторую долю машин свободными. Это можно формализовать так:

Где:
Views - число пользователей, заглянувших в приложение
1 Surge(T_{predict}) — вероятность, что они возьмут машину
Cars — количество машин, доступных в данной зоне
Buffer — доля «резерва», которую мы хотим сохранить свободной для следующих клиентов
Таким образом, мы сознательно жертвуем частью сиюминутного профита, оставляя машины свободными, чтобы можно было найти авто в пешей доступности. Чем лучше мы умеем прогнозировать, тем точнее можем регулировать доступность и радовать каршероводов.
Итоги и планы
Hex-based-модель (только исторические данные) дала быстрый старт динамике цен.
Онлайновые корректировки добавили гибкость и научились подстраиваться к всплескам и падению спроса.
Car-based-подход позволил учитывать особенности каждой машины, но потребовал серьёзной инфраструктуры (онлайн-скоринг, Kafka, «тяжёлый» ML-стек).
Глобальная оптимизация — следующий этап, в котором мы стараемся найти «оптимальную точку» цены, учитывая спрос и необходимый резерв машин, чтобы пользователь был уверен, что всегда найдет Ситидрайв поблизости.
Параллельно мы работаем над прозрачностью для клиентов и показываем в приложении пояснения, например, «в этой зоне сейчас высокий спрос, поэтому цена выше обычной».
Мы продолжаем развивать систему и совершенствовать подход к динамическому прайсингу. Главная задача — чтобы машина всегда была в пешей доступности у пользователя.
И в заключении хочу выразить особую признательность Максиму Шитилову, аналитику из группы по управлению эффективностью платформы, за его ценный вклад в сбор бизнес-фичей, оптимизацию скриптов и валидацию результатов.
Спасибо, что дочитали! Если у вас остались вопросы или идеи, рад обсудить в комментариях :)