Написание собственных функций 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.