Написание собственных функций sklearn, (пока последняя) часть 3

Если вы работали со sklearn до того, как наверняка столкнулись с трудностями между использованием фреймов данных или массивов в качестве входных данных для ваших преобразователей и оценщиков. Оба имеют свои преимущества и недостатки. Но как только вы развернете свою модель, например, как услугу, во многих случаях она будет обслуживать отдельные прогнозы. Макс Халфорд показал несколько отличных примеров того, как улучшить различные преобразователи склеарна и средства оценки для обслуживания отдельных прогнозов с дополнительным повышением производительности и потенциальными откликами в диапазоне малых миллисекунд! В этом коротком посте мы рассмотрим эти приемы и разработаем полный конвейер.

Несколько месяцев назад Макс Халфорд написал потрясающий блог, в котором описал, как мы можем модифицировать преобразователи и средства оценки sklearn для обработки отдельных точек данных с более высокой скоростью, по сути, используя одномерные массивы. Когда вы строите конвейеры модели sklearn, они обычно работают с несколькими массивами и фреймами данных pandas одновременно. Массивы часто обеспечивают лучшую производительность, потому что реализации numpy для многих вычислений высокопроизводительны и часто векторизованы. Но также становится сложнее управлять преобразованиями, используя имена столбцов, которых у массивов нет. Если вы используете фреймы данных pandas, вы можете получить худшую производительность, но ваш код может стать более читаемым, а имена столбцов (то есть имена функций) будут соответствовать данным для большинства преобразователей. Во время исследования данных и обучения модели вас в основном интересуют пакетные преобразования и прогнозы, но как только вы развернете свой обученный конвейер модели как услугу, вас также могут заинтересовать отдельные прогнозы. В обоих случаях пользователи сервиса отправят полезную нагрузку, как показано ниже.

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

{
    "species": "Bream",
    "length": 24.5,
    "heigth": 12.3,
    "width": 4.12,
}

или, альтернативно, ["Bream", 24.5, 12.3, 4.12], и модель может возвращать оценку веса следующим образом:

В своем блоге Макс Халфорд показал, как можно добавить методы transform_single и predict_single к преобразователям и оценщикам для выполнения обработки отдельных точек данных с более высокой производительностью. В зависимости от сложности конвейера абсолютное количество сэкономленного времени может быть небольшим. Но более высокое время отклика приведет к общей задержке вашей сервисной инфраструктуры, а короткое время будет оказывать давление на приложение, особенно если оно находится в пределах критического пути. Мы также сможем сэкономить на стоимости инфраструктуры, поскольку сможем запускать ваши услуги на меньшем оборудовании, то есть на меньших и меньших модулях. Более того, предотвращение принуждения фрейма данных освободит память в обслуживающих экземплярах. И последнее, но не менее важное: мы экономим время, которое мы можем потратить на более сложные преобразования и модели - то, что делает счастливым каждого специалиста по данным!

Создание простых трансформаторов

Но какова цена за сокращение времени отклика? Мы можем изучить это, посмотрев на пример, без рекламы наследования классов здесь, а скорее как набросок того, как это может работать:

barebones_transformer = BarebonesTransformer()
barebones_transformer.fit(data)
barebones_transformer.transform_single([1.0, 2.5])

С одной стороны, мы рискуем потерять паритет обучения и умозаключений / предсказаний. Что это значит? Как мы видим выше, у нас, по сути, есть два разных кодовых пути, которые наши данные могут использовать во время преобразования: кодовый путь для одиночных прогнозов будет использоваться только для вывода отдельных точек данных, но не во время обучения, где мы обычно трансформируем партиями, т. Е. фреймы данных или массивы. Поэтому нам нужно приложить дополнительные усилия, чтобы убедиться, что оба пути преобразования приводят к одному и тому же преобразованию и, следовательно, к одним и тем же результатам. Это может быть достигнуто, например, путем добавления дополнительных модульных тестов.

С другой стороны, мы можем потерять часть проверки, которую sklearn выполняет внутри, то есть когда вызывается родительский метод transform. Поэтому нам нужно убедиться, что наши полезные данные правильно проверены, прежде чем они будут переданы в модель, чтобы модель не вышла из строя.

Те же идеи применимы к оценщикам и методу predict. В конце концов, это похоже на отправку письма на грузовике (плюс страховка), это работает, это безопасный вариант, но он может быть чрезмерным, и почтовый перевозчик на велосипеде может быть более подходящим и быстрым.

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

Теперь, когда мы увидели, как мы можем заставить это работать, давайте оценим производительность подходов на основе pandas и numpy, используя какой-нибудь игрушечный день и SimpleImputer sklearn, преобразователь, который вменяет недостающие данные, например, используя среднее значение. Мы будем использовать достаточно надежный pd.isna для проверки отсутствующих значений в нашем 1d-массиве:

import pandas as pd
import numpy as np
np.random.seed(47723)
# truncate decimals for better printing
np.set_printoptions(precision=3, suppress=True)
pd.set_option('precision', 3)
n = 1000
data = pd.DataFrame({
    'num1': np.random.normal(0, 1, n),
    'num2': np.random.normal(0, 1, n)
})
# remove 10% of the data
data[np.random.rand(*data.shape) > 0.9] = np.nan
data.head()
##     num1   num2
## 0  0.897 -1.626
## 1  1.370  0.279
## 2    NaN -0.652
## 3  1.379 -0.164
## 4  0.450    NaN

SimpleImputer сохраняет подогнанные значения вменения в self.statistics_ (по соглашению подогнанные значения всегда заканчиваются подчеркиванием):

from sklearn.impute import SimpleImputer
simple_imputer = SimpleImputer(strategy='mean')
simple_imputer.fit_transform(data)
## array([[ 0.897, -1.626],
##        [ 1.37 ,  0.279],
##        [ 0.071, -0.652],
##        ...,
##        [-0.233,  0.741],
##        [ 0.071, -0.627],
##        [-1.056, -0.622]])
simple_imputer.statistics_
## array([0.071, 0.016])

Мы можем использовать эти значения в наших transform_single заполнении пропущенных значений:

Расчет минимальных трансформаторов

Давайте теперь оценим улучшение производительности. Мы будем использовать timeit и несколько простых вспомогательных функций для измерения времени в миллисекундах:

from timeit import timeit
def time_func_call(call: str, n: int = 1000):
  t = timeit(call, globals = globals(), number=n) / n
  t_ms = np.round(t * 1000, 4)
  return t_ms
time_func_call('barebones_simple_imputer.transform(data)')
## 3.0503
time_func_call('barebones_simple_imputer.transform_single([1.2, np.nan])')
## 0.0701

Мы определим еще одну вспомогательную функцию, которая сравнивает и красиво выводит время вызова нескольких функций:

from typing import List
def time_func_calls(calls: List[str]):
    max_width = np.max([len(call) for call in calls])
    for call in calls:
        t_ms = time_func_call(call)
        print(f'{call:{max_width}}: {t_ms:.4f}ms')
    return

Теперь мы можем применить это к множественным и одиночным точкам данных в форме фреймов данных и множественных массивов:

Таким образом, преобразование с одной точкой данных превосходит другие реализации. Давайте быстро рассмотрим OneHotEncoder, еще один очень полезный преобразователь, который кодирует категориальные переменные с помощью фиктивных переменных. Мы также снова определим некоторые данные игрушек:

n = 3000
data = pd.DataFrame({
    'cat1': np.random.choice(['a', 'b', 'c'], n),
    'cat2': np.random.choice(['x', 'y'], n)
})
data.head()
##   cat1 cat2
## 0    a    x
## 1    b    x
## 2    b    y
## 3    a    x
## 4    b    y

OneHotEncoder сохраняет изученные категории в списке в self.categories_, откуда мы можем взять их и использовать для кодирования категориальных переменных:

barebones_one_hot_encoder = BarebonesOneHotEncoder(sparse=False, handle_unknown='ignore')
barebones_one_hot_encoder.fit_transform(data)
## array([[1., 0., 0., 1., 0.],
##        [0., 1., 0., 1., 0.],
##        [0., 1., 0., 0., 1.],
##        ...,
##        [0., 0., 1., 0., 1.],
##        [1., 0., 0., 1., 0.],
##        [0., 1., 0., 1., 0.]])
barebones_one_hot_encoder.categories_
## [array(['a', 'b', 'c'], dtype=object), array(['x', 'y'], dtype=object)]
barebones_one_hot_encoder.transform_single(['b', 'x'])
## array([0, 1, 0, 1, 0])

Давайте снова проведем сравнительный анализ различных случаев:

Кодеру теперь требуется всего 0,02 мс (миллисекунды) вместо 0,5 мс, что улучшилось примерно в 25 раз. Теперь давайте объединим все это вместе и измерим общее улучшение производительности общего конвейера. Мы получим некоторый набор данных, называемый набором данных рыбного рынка, который содержит измерения размеров и категоризацию рыб, где мы хотим спрогнозировать их вес.

Данные выглядят следующим образом:

x.head()
##   species  length1  length2  length3  height  width
## 0   Bream     23.2     25.4     30.0  11.520  4.020
## 1     NaN     24.0     26.3     31.2  12.480  4.306
## 2   Bream     23.9     26.5     31.1  12.378  4.696
## 3   Bream     26.3     29.0     33.5  12.730  4.455
## 4   Bream     26.5     29.0     34.0  12.444  5.134
y.head()
## 0    242.0
## 1    290.0
## 2    340.0
## 3    363.0
## 4    430.0
## Name: weight, dtype: float64

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

  • реализовать transform_single аналогично transform, например. используя self._iter
  • реализовать преобразователь идентичности с transform_single, который можно передать для обработки остатка, то есть оставшихся столбцов

Создание быстрого конвейера

Если мы хотим использовать в конвейере простые преобразователи и оценщики, мы должны изменить и сам конвейер, добавив predict_single аналогичный прогноз, который использует transform_single методы преобразователей и вызывает predict_single модели. , как Макс также описывает в своем посте.

Теперь мы можем построить наш конвейер. Мы будем вменять категориальной переменной наиболее частое значение, а числовые значения - средним (здесь не самые умные методы вменения, поскольку между ними существует сильная связь, и подход с условным средним или ближайшим соседом был бы лучше). Затем мы в горячем режиме закодируем категориальную переменную и обучим линейную модель на данных.

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

Давайте наконец оценим, что оба прогноза идентичны. Запуск predict по-прежнему использует полноценный путь кода sklearn, в отличие от нашего облегченного {transform,predict}_single метода:

batch_predictions = barebones_pipeline.predict(x)
batch_predictions[0:5]
## array([285.874, 418.604, 363.433, 417.214, 459.909])
single_predictions = [barebones_pipeline.predict_single(x_i) for x_i in x.to_numpy()]
single_predictions[0:5]
## [285.873, 418.603, 363.433, 417.214, 459.909]
np.all(np.isclose(batch_predictions, single_predictions, atol = 0.0001))
## True

Заключение

Мы увидели, что можем ускорить наш конвейер в 20–25 раз для отдельных прогнозов (от 2,4 мс до 0,1 мс). Но чем больше преобразований мы добавим, тем более ценным будет ускорение и тем очевиднее станет компромисс. Мы увидели, как мы можем использовать настраиваемые преобразователи или настраивать существующие для ускорения преобразований и прогнозов отдельных точек данных за счет дополнительного времени, затрачиваемого на разработку (особенно, если преобразование более сложное), и дополнительных усилий, затрачиваемых на обучение-вывод. четность, модульные тесты и проверка данных.

Реплика: профилирующие трансформаторы

Если вы пытаетесь найти узкие места ваших трансформаторов, я рекомендую использовать line_profiler и memory_profiler. Они могут контролироваться не на всем конвейере (вы должны передать ему все отдельные функции), а на отдельных трансформаторах. Вы можете использовать профилировщик с помощью магии следующим образом:

или без магии:

Первоначально опубликовано на https://blog.telsemeyer.com.