Всем привет!

Это третья статья о там, как я делаю небольшой и уютный сервис, который в теории должен помочь с планированием путешествий. В этой статье я расскажу про то, как предсказывать цены на авиабилеты, имея под рукой Clickhouse, Catboost и 1TB* данных.

image

Для чего это нужно?


Одна из основных фичей cheapster.travel — это гибкое комбинирование сложных маршрутов (подробнее в предыдущей статье). Для того, чтобы комбинировать «все-со-всем» используется кэш агрегаторов, в котором не всегда есть билеты, которые редко ищут, а их катастрофически не хватает, чтобы строить сложные маршруты. Т.е. горячие билеты (дешевые) на котором будет основываться сложный маршрут есть, но при этом не хватает 1-2 сегментов из «обычных» билетов (по обычной цене, на не самом популярном направлении). Именно эта проблема привела меня к необходимости построить модель, которая смогла бы предсказывать цены на авиабилеты.

Формализация задачи


  • Нужно уметь предсказывать билеты на прямые рейсы (только туда или туда-обратно)
  • Нужно уметь регулярно предсказывать и сохранять это в базу (простой сценарий)
  • Нужно уметь предсказывать «на лету» (сложный сценарий)
  • Это все происходит на сильно ограниченном железе — поэтому минимум манипуляций с большими объемами данных

Как это сделать?


Для начала обучим модель: готовим датасет, выделяя максимальное кол-во фичей в колонки, выгружаем его в tsv, загружаем его в DataFrame/Pool, проводим анализ, подбираем параметры... Стоп, у нас слишком много данных и они не помещаются в память, — ловим такие ошибки:

MemoryError: Unable to allocate array with shape (38, 288224989) and data type float64
OSError: [Errno 12] Cannot allocate memory

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

model = CatBoostRegressor(cat_features=cat_features,
          iterations=100,
          learning_rate=.5,
          depth=10,
          l2_leaf_reg=9,
          one_hot_max_size=5000)

for df in tqdm(pd.read_csv('history.tsv', sep='\t', 
                           na_values=['\\N'], 
                           chunksize=2_000_000)):
    ...
     model.fit(X=df[df.columns[:-1]][:train_size].values,
                  y=df['price'][:train_size].values,
                  eval_set=eval_pool,
                  verbose=False,
                  plot=False,
                  init_model=model) # <-- В каждой итерации на вход подается предыдущая модель

В итоге получилась модель с RMSE~100 — в целом меня бы устроил и такой результат, но после небольшого анализа и «нормализации» предсказаний (отрицательные и значения, которые сильно отличаются от min/max значений в истории, приведены к соответствующим границам исторических цен). После этого целевая метрика~80, с учетом того, что по моему опыту, логики и здравого смысла при формировании цен на авиабилеты почти нет.

Фичи, которые больше всего влияют на цену:

image

Статистика для фичи «Расстояние между городами»:

image

Отлично, модель у нас есть — теперь пора ее использовать. Первым делом, добавляем модель КХ, это делается простым конфигом:

Конфиг
<models>
    <model>
        <!-- Model type. Now catboost only. -->
        <type>catboost</type>
        <!-- Model name. -->
        <name>price</name>
        <!-- Path to trained model. -->
        <path>/opt/models/price_iter_model_2.bin</path>
        <!-- Update interval. -->
        <lifetime>0</lifetime>
    </model>
</models>


Делаем регулярный батчевый процесс предсказания — это достаточно просто сделать с помощью Apache Airflow.

Получившийся DAG выглядит так
image
Один элемент DAGa выглядит так(для тех кто не знаком с Airflow):

SimpleHttpOperator
insert_ow_in_tmp = SimpleHttpOperator(
    task_id='insert_ow_in_tmp',
    http_conn_id='clickhouse_http',
    endpoint=dll_endpoint,
    method='POST',
    data=sql_templates.INSERT_OW_PREDICTIONS_IN_TMP,
    pool='clickhouse_select',
    dag=dag
)



Для предсказания «на лету» используется обычный sql:

select origin, destination, date,
         modelEvaluate('price', *)  predicted_price -- да, именно так просто
from log.history

+--------+-------------+------------+-----------------+
| origin | destination | date       | predicted_price |
+--------+-------------+------------+-----------------+
| VKO    | DEB         | 2020-03-20 | 3234.43244      |
+--------+-------------+------------+-----------------+
--*Пример сокращен, чтобы проще воспринимался

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

  • Нет необходимости выгружать данные во вне КХ (это значит быстрее и менее затратно по нагрузке на железо)
  • Не нужно делать etl-процессы (проще=надежнее)

Немного правим API и фронтенд и получаем долгожданные предсказания.

Эти предсказания также хорошо вписались в раздел История цен на авиабилеты:

image

Функционал доступен по ссылке cheapster.travel/history (на мобильном откроется криво, только большие экраны).

На этом всё, всем продуктивного дня!

Предыдущие статьи


Попытка решить проблему выбора авиабилетов перед отпуском
Попытка решить проблему выбора авиабилетов перед отпуском #2

Другой интересный функционал


Комбинатор сложных маршрутов
Сложные билеты (треугольники)

P.S.
Важно! Не воспринимайте эти предсказания, как что-то что помогает выбрать дату покупки — модель может предсказать неправильно, более того ее адекватность не проверена мной или кем-либо другим (все на свой страх и риск, без гарантий).

1TB* — это если выгрузить в tsv, в КХ это занимает на порядок меньше.