Привет, Хабр! Меня зовут Иван Сивков, я наставник на курсе «Специалист по Data Science» в Яндекс Практикуме. В этой статье покажу, как построить пайплайн в библиотеке scikit-learn на базе встроенных инструментов и сократить количество кода при преобразовании данных. Эта статья рассчитана на новичков, которые только начинают изучать Data Science, но уже знают основные понятия.

В тексте упоминается scikit-learn — одна из самых популярных Python-библиотек для классического машинного обучения. Кроме большого числа алгоритмов машинного обучения, с помощью scikit-learn можно строить пайплайны. Это цепочки функций, через которые можно проводить данные. Пайплайны похожи на конвейеры, где каждое звено выступает в роли преобразователя (трансформера) или модели-предсказателя (обычно это последнее звено).

Важный этап обработки данных  — их преобразование. Без пайплайна данные должны проходить через отдельные преобразователи: энкодеры (преобразуют категориальные признаки в числовые векторы), импьютеры (заполняют пропуски в данных) и скейлеры (приводят признаки к одному масштабу). Каждый инструмент по отдельности нужно натренировать на обучающей выборке, преобразовать её и отдельно сделать преобразование тестовой выборки. В результате получается много повторяющегося кода.

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

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

Подготовка

  1. Первым шагом установим минимально нужную версию библиотеки — 1.1. Если выполняете код в Jupiter Hub Яндекс Практикума, то базовая версия sklearn — 0.24. Нужно установить более позднюю, чтобы избежать проблем с совместимостью.

!pip install scikit-learn==1.1
  1. Затем импортируем pandas для загрузки данных, а также все инструменты sklearn, которые понадобятся в этом примере: скейлеры, энкодеры и импьютеры. В списке есть вспомогательные инструменты для разделения выборок, рассчитывания метрик и других операций.

import pandas as pdfrom sklearn.compose import ColumnTransformer

from sklearn.impute import KNNImputer, SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.model_selection import train_test_split 
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.metrics import mean_squared_error
  1. Убедимся, что версия библиотеки не ниже требуемой. Показываю для примера — в реальной разработке этот шаг не нужен.

import sklearn
sklearn.__version__
  1. Отключим предупреждения о будущем изменении названия аргумента sparse объекта OneHotEncoder на sparse_output, чтобы не мешало отображению вывода.  Впрочем это предупреждение будет выводиться только, если используете версию scikit-learn 1.2 и выше. Также ограничим вывод датафреймов восемью столбцами.

    В таком виде они будут влезать на один экран, и работать с ними будет удобнее. 

import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
pd.set_option('display.max_columns',  8)
  1. Для этого примера возьмём популярный бесплатный датасет с информацией о стоимости недвижимости в Калифорнии, который часто упоминают в обучающих статьях и книгах по Data Science. Скачаем его с сайта компании Hugging Face — разработчика библиотеки Transformers (содержит нейронные сети и другие инструменты для работы с текстовыми данными).

data = pd.read_csv('https://huggingface.co/datasets/leostelon/california-housing/raw/main/housing.csv')
data.info()
display(data.describe())
  1. Следующим шагом выбираем целевой признак medium_house_value — это медианная цена за дом для округа. Численными признаками будут медианное значение возраста домов, общее количество комнат и спален в них, население, число домов, медианный доход на семью. А категориальный признак — положение относительно океана.

А затем разделяем датасет на рабочую и тестовую выборки:

target = data['median_house_value']
features = data.drop(['median_house_value'], axis=1)
features_train, features_test, target_train, target_test = train_test_split(features, target, test_size=0.2, random_state=44)

На этом подготовительная часть закончена — перейдём к созданию пайплайна. 

Пример пайплайна

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

  1. Начнём с численных. Задача пайплайна pipe_num на этом этапе — заполнить пропуски и, если они есть, провести масштабирование признаков. 

Для этого используем инструмент SimpleImputer, заполним с его помощью пропуски медианным значением признака.

StandardScaler стандартизирует данные — вычитает среднее и делит на среднее квадратичное обучающей выборки.

В последней строке задаём объект класса Pipeline. При этом передаём на вход список, каждый элемент в котором — кортеж из двух значений. Это трансформер и его название, которое мы задаём самостоятельно, чтобы в случае необходимости обратиться к нему.

simple_imputer = SimpleImputer(strategy='median')
std_scaler = StandardScaler()
pipe_num = Pipeline([('imputer', simple_imputer), ('scaler', std_scaler)])
  1. Следующим шагом через pipe_num вызываем fit_transform — происходит то же самое, как если бы мы отдельно применяли SimpleImputer и StandardScaler. Здесь используются все признаки, кроме категориального.

res_num = pipe_num.fit_transform(features_train.drop(['ocean_proximity'], axis=1))

Трансформатор выше возвращает данные в виде NumPy-массива, столбцы в котором не названы. Чтобы вернуть названия столбцов для удобства работы с данными, можно использовать метод get_feature_names_out объекта пайплайна.

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

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

res_df_num = pd.DataFrame(res_num, 
columns=pipe_num['scaler'].get_feature_names_out(features.drop(['ocean_proximity'], axis=1).columns))
res_df_num
  1. Это служебная строка, в которой мы проверяем, что в данных нет пропусков. В итоговый пайплайн она не попадёт и нужна исключительно для демонстрации.

res_df_num.info()
  1. Теперь создадим пайплайн для категориальных признаков. С помощью SimpleImputer заменим пропуски на значение ‘unknown’, а OneHotEncoder используем для кодирования категориальных признаков в числовые значения, понятные моделям. Аргумент handle_unknown=’ignore’ нужен для исключения ошибок в случаях, если при тестировании встречаются категории, которых не было в обучающей выборке. В конце создаём пайплайн из импьютера и энкодера и проверяем, что он сработал.

Хотя в этом датасете в категориальном признаке нет пропусков, они могут возникнуть, если при использовании модели на практике появятся новые данные. На этот случай нужно решение, которое позволит коду не упасть, — для этого и нужен импьютер.

s_imputer = SimpleImputer(strategy='constant', fill_value='unknown')
ohe_encoder = OneHotEncoder(handle_unknown='ignore', sparse=False)
pipe_cat = Pipeline([('imputer', s_imputer), ('encoder', ohe_encoder)])

res_cat = pipe_cat.fit_transform(features_train[['ocean_proximity']])

res_df_cat = pd.DataFrame(res_cat, columns=pipe_cat.get_feature_names_out())
res_df_cat
  1. Теперь объединяем признаки в один пайплайн.

Здесь есть нюанс: поставить категориальные и численные признаки просто друг за другом не удастся, их надо обрабатывать по-разному. В scikit-learn есть инструмент ColumnTransformer, который работает как компоновщик пайплайнов. С его помощью можно распараллелить пайплайн: для одной части столбцов может применяться один пайплайн, для второй — другой.

При задании объекта Column Transformer передаётся список кортежей. В каждом кортеже кроме названия и трансформера, как при создании объекта Pipeline, дополнительно указывается, для каких столбцов он применяется.

Стоит отметить, что кроме элементарных трансформеров (SimpleImputer, StandardScaler и др.) Column Transformer может принимать в качестве преобразователя и объекты Pipeline. 

С помощью инструмента list comprehension создаём списки численных и категориальных столбцов. В нашем примере датасет небольшой, и можно было бы задать списки вручную. Но на практике столбцов может быть намного больше, поэтому лучше автоматизировать процесс. В коде ниже проверяется тип столбца: если он object, то считается как численный, если нет — как категориальный. 

col_transformer = ColumnTransformer([('num_preproc', pipe_num, [x for x in features.columns if features[x].dtype!='object']),
                                     ('cat_preproc', pipe_cat, [x for x in features.columns if features[x].dtype=='object'])])
  1. В этой строке возвращаем результат выполнения пайплайна:

res = col_transformer.fit_transform(features_train)
  1. Преобразуем результат в датафрейм. При этом уберём дополнительную информацию в названиях столбцов — автоматически метод get_feature_names_out объекта ColumnTransformer добавляет название трансформера, с помощью которого были получены столбцы. Из-за этого на страницу влезает меньше столбцов, и пользоваться выводом становится неудобно.

res_df = pd.DataFrame(res, columns = [x.split('__')[-1] for x in col_transformer.get_feature_names_out()])
res_df
  1. Собираем готовый пайплайн, компонуя его части с помощью Column Transformer. Он состоит из предобработки и модели Ridge из библиотеки scikit-learn — это линейная регрессия с регуляризацией. Это одна из простых моделей, которая предсказывает целевой признак на основе суммы других признаков, умноженных на коэффициенты.

model = Ridge()

final_pipe = Pipeline([('preproc', col_transformer),
                       ('model', model)])
  1. Обучаем финальный пайплайн на обучающей выборке и делаем предсказание. 

final_pipe.fit(features_train, target_train)
preds = final_pipe.predict(features_test)
mean_squared_error(target_test, preds, squared=False)

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

simple_imputer = SimpleImputer(strategy='median')
std_scaler = StandardScaler()

pipe_num = Pipeline([('imputer', simple_imputer), ('scaler', std_scaler)])

s_imputer = SimpleImputer(strategy='constant', fill_value='unknown')
ohe_encoder = OneHotEncoder(handle_unknown='ignore', sparse=False)
pipe_cat = Pipeline([('imputer', s_imputer), ('encoder', ohe_encoder)])

col_transformer = ColumnTransformer([('num_preproc', pipe_num, [x for x in features.columns if features[x].dtype!='object']),
                                     ('cat_preproc', pipe_cat, [x for x in features.columns if features[x].dtype=='object'])])

final_pipe = Pipeline([('preproc', col_transformer),
                       ('model', model)])

final_pipe.fit(features_train, target_train)
preds = final_pipe.predict(features_test)

В итоге весь код для предобработки, обучения и предсказания занимает буквально полстраницы.

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

Научиться работать с датасетами, пайплайнами, Jupyter Notebook и многими другими инструментами, не упомянутыми в этой статье, можно на курсе «Специалист по Data Science» от Практикума. В зависимости от вашего расписания и привычного темпа работы, можно освоить программу за 5, 8 или 16 месяцев.

Комментарии (2)


  1. saege5b
    24.08.2023 17:33
    +1

    О...

    Учебник с костылингом и депрекатингом, для новичков :)

    Ну, для "здесь и сейчас" сойдёт, но в будущем будут проблемы; - это я для тех, кто будет пробовать и будет получать массовые ошибки.


    1. I_a_sivkov Автор
      24.08.2023 17:33

      Спасибо за конструктивную критику и что обратили внимание на этот момент. В момент верстки вкралась относится. Depricate warning к scikit-learn, а не pandas относится. С версии 1.4 планируется убрать название аргумента sparse и оставить sparse_output (с версии 1.2 вводится).

      Соглашусь, что эта статья именно для новичков. Цель познакомить читателя с инструментом Pipeline библиотеки sklearn, а не дать готовое решение "на все времена".