Русский
Русский
English
Статистика
Реклама

Опыт использования фреймворка Featuretools

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

Можно делать это вручную, но также для этого существуют фреймворки и библиотеки, ускоряющие и упрощающие этот процесс.

Об одной из них, featuretools, а также о практическом опыте ее использования мы сегодня и поговорим.


Моднейший пайплайн

Всем привет! Я Александр Лоскутов, работаю в Леруа Мерлен аналитиком данных, или, как это модно сейчас называть, data-scientist-ом. В мои обязанности входит работа с данными, начиная c аналитических запросов и выгрузок, заканчивая обучением модели, оборачиванием ее, например, в сервис, настройкой доставки и развертывания кода, а также мониторинга его работы.

Предсказание отмен один из продуктов, над которым я работаю.

Задача продукта: предсказать вероятность отмены заказа клиентом, сделанного онлайн. С помощью такого предсказания мы можем определить, кому из клиентов, сделавших заказ, нужно позвонить в первую очередь, чтобы попросить подтвердить заказ, а кому можно и не звонить вовсе. Во-первых, сам факт звонка и подтверждения заказа от клиента по телефону уменьшает вероятность отмены, а во-вторых, если мы позвоним и человек откажется, то мы сможем сэкономить ресурсы. У сотрудников высвободится больше времени, которое они бы потратили на то, чтобы собрать заказ. К тому же так товар останется на полке, и если в этот момент находящемуся в магазине покупателю он понадобится, то он сможет его купить. И таким образом уменьшится количество товаров, которые были собраны в отмененные позже заказы и не присутствовали на полках.

Предтечи


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

Готовое решение работает примерно так: к нам приходит заказ, с помощью Apache NiFi мы обогащаем информацию по нему например, подтягивая данные по товарам. Затем все это через брокера сообщений Apache Kafka передается в сервис, написанный на Python. Сервис рассчитывает признаки для заказа, а дальше за них берется модель машинного обучения, которая выдает вероятность отмены. После этого мы в соответствии с бизнес-логикой готовим ответ, нужно звонить клиенту сейчас или нет (например, если заказ сделан с помощью сотрудника внутри магазина или заказ сделали ночью, то звонить не стоит).

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

Разработка модели


Я занимался сервисом, моделью и, соответственно, расчетом признаков для модели, о котором и пойдет дальше речь.

При расчете признаков во время обучения мы используем три источника данных.

  1. Табличка с метаинформацией о заказе: номер заказа, timestamp, устройство клиента, способ доставки, способ оплаты.
  2. Табличка с позициями в чеках: номер заказа, артикул, цена, количество, количество товара на складе. Каждая позиция идет отдельной строкой.
  3. Таблица справочник товаров: артикул, несколько полей с категорией товара, единицы измерения, описание.

Стандартными методами Python и библиотекой pandas можно легко объединить все таблицы в одну большую, после чего, используя groupby, посчитать разного рода признаки вроде агрегатов по заказу, истории по товару, по категориям товаров и т. д. Но тут возникает несколько проблем.

  • Параллельность вычислений. Стандартный groupby работает в одном потоке, и на больших данных (до 10 млн строк) сотня признаков считается уже неприемлемо долго, притом что мощности остальных ядер простаивают.
  • Количество кода: каждый такой запрос нужно отдельно написать, проверить на правильность, а потом все результаты еще нужно собрать. Это требует времени, особенно учитывая сложность некоторых вычислений например, расчета последней истории по товару в чеке и агрегации этих признаков для заказа.
  • Можно допустить ошибки, если все кодить вручную.

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

Возникает вопрос: как можно оптимизировать эту часть работы. Одним из решений является использование библиотеки featuretools.

Тут мы уже переходим к сути этой статьи, а именно к самой библиотеке и практике ее применения.

Почему featuretools?


Рассмотрим различные фреймворки для машинного обучения в виде таблички (сама картинка честно украдена отсюда, и наверняка там указаны не все, но все же):



Нас интересует в первую очередь блок Feature Engineering. Если рассмотреть все эти фреймворки и пакеты, то выяснится, что самым навороченным из них является featuretools, и он даже включает в себя функциональность некоторых других библиотек вроде tsfresh.

Также к достоинствам featuretools (совсем не реклама!) можно отнести:

  • параллельное вычисление из коробки
  • доступность множества признаков из коробки
  • гибкость в настройке можно считать довольно сложные штуки
  • учет отношений между разными табличками (реляционность)
  • меньше кода
  • меньшая вероятность допустить ошибку
  • само собой все бесплатно, без регистрации и СМС (но с pypi)

Но не все так просто.

  • Фреймворк требует некоторого изучения, а полное освоение и вовсе займет приличное количество времени.
  • У него не такое большое комьюнити, хотя самые популярные вопросы все же хорошо гуглятся.
  • Само использование также требует аккуратности, чтобы не раздувать пространство признаков без надобности и не увеличивать время расчета.

Обучение


Приведу пример конфигурации featuretools.

Далее будет код с краткими объяснениями, более подробно про featuretools, его классы, методы, возможности можно почитать в том числе в документации на сайте фреймворка. Если вам будут интересны примеры практического применения с демонстрацией некоторых интересных возможностей в реальных задачах, то пишите в комментариях, возможно, запилю отдельную статью.

Итак.

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

Напомню, что у нас три таблицы с данными:

  • orders_meta (мета-информация о заказах)
  • orders_items_lists (информация о позициях в заказах)
  • items (справочник артикулов и их свойств)

Пишем (далее используются данные только 3-х магазинов):

import featuretools as ftes = ft.EntitySet(id='orders')  # создаем пустой объект класса EntitySet# по очереди добавляем в него pandas.DataFrame-ы как сущности (ft.Entity)es = es.entity_from_dataframe(entity_id='orders_meta',                              dataframe=orders_meta,                              index='order_id',                              time_index='order_creation_dt')es = es.entity_from_dataframe(entity_id='orders_items',                              dataframe=orders_items_lists,                              index='order_item_id')es = es.entity_from_dataframe(entity_id='items',                              dataframe=items,                              index='item',                              variable_types={                                  'subclass': ft.variable_types.Categorical                              })# объявляем отношения между сущностями# для задания отношения сначала указываем сущность-родителя,# затем сущность-ребенкаrelationship_orders_items_list = ft.Relationship(es['orders_meta']['order_id'],                                                 es['orders_items']['order_id'])relationship_items_list_items = ft.Relationship(es['items']['item'],                                                es['orders_items']['item'])# добавляем отношенияes = es.add_relationship(relationship_orders_items_list)es = es.add_relationship(relationship_items_list_items)



Ура! Теперь у нас есть объект, который позволит нам считать разного рода признаки.

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

orders_aggs, orders_aggs_cols = ft.dfs(    entityset=es,    target_entity='orders_meta',    agg_primitives=['mean', 'count', 'mode', 'any'],    trans_primitives=['hour', 'weekday'],    instance_ids=[200315133283, 200315129511, 200315130383],    max_depth=2)





Здесь в таблицах нет столбцов типа boolean, поэтому примитив any не был применен. Вообще удобно, что featuretools сам анализирует тип данных и применяет лишь соответствующие функции.

Также я вручную указал только несколько заказов для расчета. Это позволяет быстро дебажить свои вычисления (вдруг вы сконфигурировали что-то не то).

Теперь добавим к нашим признакам еще несколько агрегатов, а именно перцентили. Но в featuretools нет встроенных примитивов для их расчета. Значит, нужно написать самим.

from featuretools.variable_types import Numericfrom featuretools.primitives.base.aggregation_primitive_base import make_agg_primitivedef percentile05(x: pandas.Series) -> float:   return numpy.percentile(x, 5)def percentile25(x: pandas.Series) -> float:   return numpy.percentile(x, 25)def percentile75(x: pandas.Series) -> float:   return numpy.percentile(x, 75)def percentile95(x: pandas.Series) -> float:   return numpy.percentile(x, 95)percentiles = [percentile05, percentile25, percentile75, percentile95]custom_agg_primitives = [make_agg_primitive(function=fun,                                            input_types=[Numeric],                                            return_type=Numeric,                                            name=fun.__name__)                         for fun in percentiles]

И добавим их в расчет:

orders_aggs, orders_aggs_cols = ft.dfs(    entityset=es,    target_entity='orders_meta',    agg_primitives=['mean', 'count', 'mode', 'any'] + custom_agg_primitives,    trans_primitives=['hour', 'weekday'],    instance_ids=[200315133283, 200315129511, 200315130383],    max_depth=2)

Дальше все то же самое. Пока все довольно просто и легко (относительно, конечно).

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

Featuretools в бою


Тут-то и начинаются основные сложности.

Для расчета признаков по входящему заказу придется опять проделать все операции с созданием EntitySet. И если для больших таблиц закидывать pandas.DataFrame-объекты в EntitySet кажется вполне нормальным, то делать аналогичные операции для DataFrame-ов из одной строки (в таблице с чеками их больше, но в среднем 3.3 позиции на один чек, то есть мало) уже не очень. Ведь создание таких объектов и расчеты с их помощью неизбежно содержат оверхед, то есть неустранимое количество операций, необходимых, например, для выделения памяти и инициализации при создании объекта любого размера или самого процесса распараллеливания при вычислении нескольких признаков одновременно.

Поэтому в режиме работы один заказ за раз в нашем продукте featuretools показывает не лучшую эффективность, занимая в среднем 75% времени ответа сервиса (в среднем 150-200 мс в зависимости от железа). Для сравнения: вычисление предсказания с помощью catboost-а на готовых признаках занимает 3% от времени ответа сервиса, т. е. не более 10 мс.

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

Тогда почему бы не воспользоваться встроенной функцией save_features(), которая может сохранить список объектов класса FeatureBase, в том числе созданные нами примитивы?
Сохранить-то она их сохранит, а вот прочитать потом с помощью функции load_features() их не получится, если мы заранее их снова не создадим. То есть примитивы, которые мы по идее должны прочитать с диска, мы предварительно снова создаем, чтобы больше никогда их не использовать.

Выглядит это примерно так:

from __future__ import annotationsimport multiprocessingimport picklefrom typing import List, Optional, Any, Dictimport pandasfrom featuretools import EntitySet, dfs, calculate_feature_matrix, save_features, load_featuresfrom featuretools.feature_base.feature_base import FeatureBase, AggregationFeaturefrom featuretools.primitives.base.aggregation_primitive_base import make_agg_primitive# класс-калькулятор признаков# при инициализации мы указываем целевую сущность,# для объектов которой мы будем считать признаки# передаем также список агрегирующих примитивов# еще можно передать список наших кастомных примитивов (в виде параметров их создания),# глубину расчета признаков и паттерны признаков, которые мы считать не хотимclass AggregationFeaturesCalculator:    def __init__(self,                 target_entity: str,                 agg_primitives: List[str],                 custom_primitives_params: Optional[List[Dict[str, Any]]] = None,                 max_depth: int = 2,                 drop_contains: Optional[List[str]] = None):        if custom_primitives_params is None:            custom_primitives_params = []        if drop_contains is None:            drop_contains = []        self._target_entity = target_entity        self._agg_primitives = agg_primitives        self._custom_primitives_params = custom_primitives_params        self._max_depth = max_depth        self._drop_contains = drop_contains        self._features = None  # список признаков (объектов типа ft.FeatureBase)    @property    def features_are_built(self) -> bool:        return self._features is not None    @property    def features(self) -> List[AggregationFeature]:        if self._features is None:            raise AttributeError('features have not been built yet')        return self._features    # метод для создания списка признаков из наших примитивов    def build_features(self, entity_set: EntitySet) -> None:        custom_primitives = [make_agg_primitive(**primitive_params)                             for primitive_params in self._custom_primitives_params]        self._features = dfs(            entityset=entity_set,            target_entity=self._target_entity,            features_only=True,            agg_primitives=self._agg_primitives + custom_primitives,            trans_primitives=[],            drop_contains=self._drop_contains,            max_depth=self._max_depth,            verbose=False        )    # функция, считающая матрицу значений признаков    # для некоторых списка признаков и набора сущностей    @staticmethod    def calculate_from_features(features: List[FeatureBase],                                entity_set: EntitySet,                                parallelize: bool = False) -> pandas.DataFrame:        n_jobs = max(1, multiprocessing.cpu_count() - 1) if parallelize else 1        return calculate_feature_matrix(features=features, entityset=entity_set, n_jobs=n_jobs)    # непосредственно вызываемый метод класса для расчета признаков    def calculate(self, entity_set: EntitySet, parallelize: bool = False) -> pandas.DataFrame:        if not self.features_are_built:            self.build_features(entity_set)        return self.calculate_from_features(features=self.features,                                            entity_set=entity_set,                                            parallelize=parallelize)    # метод для сохранения калькулятора        # список признаков сразу запиклить нельзя,    # поэтому сначала вызывается метод save_features()    # после чего пиклятся все аргументы конструктора     @staticmethod    def save(calculator: AggregationFeaturesCalculator, path: str) -> None:        result = {            'target_entity': calculator._target_entity,            'agg_primitives': calculator._agg_primitives,            'custom_primitives_params': calculator._custom_primitives_params,            'max_depth': calculator._max_depth,            'drop_contains': calculator._drop_contains        }        if calculator.features_are_built:            result['features'] = save_features(calculator.features)        with open(path, 'wb') as f:            pickle.dump(result, f)    # метод для загрузки ранее сохраненного калькулятора    @staticmethod    def load(path: str) -> AggregationFeaturesCalculator:        with open(path, 'rb') as f:            arguments_dict = pickle.load(f)                # нужно инициализировать кастомные примитивы...        if arguments_dict['custom_primitives_params']:            custom_primitives = [make_agg_primitive(**custom_primitive_params)                                 for custom_primitive_params in arguments_dict['custom_primitives_params']]        features = None                # иначе в этом месте будет ошибка         if 'features' in arguments_dict:            features = load_features(arguments_dict.pop('features'))        calculator = AggregationFeaturesCalculator(**arguments_dict)        if features:            calculator._features = features        return calculator

В функции load() приходится создавать примитивы (объявление переменной custom_primitives), которые не будут использоваться. Но без этого дальнейшая загрузка признаков в месте вызова функции load_features() упадет с ошибкой RuntimeError: Primitive percentile05 in module featuretools.primitives.base.aggregation_primitive_base not found.

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

Возможно, в будущем это поправят и можно будет удобно сохранять произвольный набор объектов FeatureBase.

Почему же тогда мы его используем?


Потому что с точки зрения времени разработки это дешево, при этом время ответа при существующей нагрузке укладывается в наш SLA (5 секунд) с запасом.

Однако стоит отдавать себе отчет в том, что для сервиса, который должен быстро отвечать на часто поступающие запросы, использовать featuretools без дополнительных приседаний вроде асинхронных вызовов будет проблематично.

Таков наш опыт использования featuretools на этапах обучения и inference-а.

В качестве инструмента для быстрого расчета большого числа признаков для обучения этот фреймворк очень хорош, сильно сокращает время разработки и уменьшает вероятность ошибок.
Использовать ли его на этапе вывода зависит от ваших задач.
Источник: habr.com
К списку статей
Опубликовано: 21.07.2020 16:21:44
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании леруа мерлен

Big data

Data engineering

Python

Машинное обучение

Featuretools

Python3

Machine learning

Feature extraction

Feature engineering

Data science

Категории

Последние комментарии

© 2006-2020, personeltest.ru