В этом небольшом практическом руководстве мы увидим некоторые конвейеры в действии и изучим несколько стратегий, которые сделают конвейеры еще более удобными. Мы будем использовать набор данных о пингвинах Эллисон Хорст, импортированный через Seaborn, чтобы построить модель, которая может предсказать вид пингвина. Характеристики, перечисленные для каждого пингвина, - это домашний остров, размеры клюва и ласта, масса тела и пол. Три вида пингвинов в наборе данных: Адели, Gentoo и Chinstrap. Все они очаровательны, но особенно Адели.

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

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# To print some nice tables (https://pypi.org/project/tabulate/)
from tabulate import tabulate

# Load in the penguins
penguins = sns.load_dataset("penguins")
display(penguins.head())
print(penguins.shape)

(344, 7)

Предположим, мы уже провели некоторый исследовательский анализ данных (EDA), чтобы увидеть распределение каждой функции, потенциальные отношения между ними и т. Д., И мы готовы провести некоторое моделирование. Как обычно, мы начнем с отделения наших функций от целевых классов с помощью Scikit-learn’s test_train_split. Поскольку мы пытаемся предсказать вид пингвина, наша цель - y.

from sklearn.model_selection import train_test_split

# Separate features from target
X = penguins.drop('species', axis=1)
y = penguins['species']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.20, random_state=42)

# Print out the sizes
shape_table = [['Original', X.shape, y.shape], ['Training', X_train.shape, y_train.shape], 
         ['Testing', X_test.shape, y_test.shape]]
print(tabulate(shape_table, headers=['Dataset', 'X shape', 'y shape']))
Dataset    X shape    y shape
---------  ---------  ---------
Original   (344, 6)   (344,)
Training   (275, 6)   (275,)
Testing    (69, 6)    (69,)

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

penguins.isna().sum()
species               0
island                0
bill_length_mm        2
bill_depth_mm         2
flipper_length_mm     2
body_mass_g           2
sex                  11
dtype: int64

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

Итак, следующие шаги, которые нам нужно сделать:

  1. Заполните отсутствующие значения:
  • Среднее значение числовых характеристик
  • Режим категориальных характеристик

2. Масштабируйте числовые данные.

3. Быстрое кодирование категориальных данных.

4. Подбирайте модель (мы просто воспользуемся простой логистической регрессией).

5. Оцените модель.

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

Но что не так с тем, как я делаю сейчас?

До конвейеров мой рабочий процесс мог выглядеть примерно так:

from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_val_score

# I want to fill in missing values, 
# but some of my columns are categorical and some are numerical
num_imputer = SimpleImputer(strategy='median')
cat_imputer = SimpleImputer(strategy='most_frequent')

# Apply each imputer to the correct columns by selecting datatypes
X_train_num_imputed = num_imputer.fit_transform(X_train.select_dtypes(include=['int64', 'float64']))
X_train_cat_imputed = cat_imputer.fit_transform(X_train.select_dtypes(include='object'))

# Might as well scale the numerical stuff...
ss = StandardScaler()
X_train_num_imputed_scaled = ss.fit_transform(X_train_num_imputed)

# ...and one-hot-encode the categorical stuff
ohe = OneHotEncoder(handle_unknown='ignore', sparse=False)
X_train_cat_imputed_ohe = ohe.fit_transform(X_train_cat_imputed)

# Now I gotta put 'em back together
X_train_preprocessed = np.concatenate([X_train_num_imputed_scaled, X_train_cat_imputed_ohe], axis=1)

# And finally fit and evaluate the model
logreg = LogisticRegression(random_state=42)
logreg.fit(X_train_preprocessed, y_train)
initial_score = logreg.score(X_train_preprocessed, y_train)
initial_crossval_score = cross_val_score(logreg, X_train_preprocessed, y_train).mean()

# Print out scores
scores_table = [['Original', initial_score, initial_crossval_score]]
scores_headers = ['Dataset', 'Training score', 'Cross-val score']
print(tabulate(scores_table, headers=scores_headers))
Dataset      Training score    Cross-val score
---------  ----------------  -----------------
Original           0.996364           0.992727

Какой беспорядок!

Если вы пропустили этот блок кода, я не могу вас винить. Он повторяющийся и плохо организованный. Я разделил и переименовал свой набор данных полдюжины раз, а затем снова собрал все вместе. В первый раз, когда я попытался запустить его, мне пришлось отладить несколько ошибок, большинство из которых возникло из-за простых опечаток из-за разных имен, которые я написал для каждой новой версии X_train. Если я захочу изменить или добавить что-нибудь позже, мне придется поискать свой код и переименовать кучу вещей. И, что хуже всего, мне придется повторить этот весь процесс с X_test, чтобы провести окончательную оценку моей модели!

Трубопроводы: лучший способ

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

Так же, как функции хранят процессы, которые вы можете запускать снова и снова, класс конвейера sklearn хранит экземпляры других классов для запуска в вашем наборе данных. Некоторые классы являются преобразователями, а другие - оценщиками. Трансформеры сражаются с десептиконами - подождите, это неправильно.

  • Трансформаторы обрабатывают или изменяют ваши данные: StandardScaler и OneHotEncoder могут использоваться в трансформаторе.
  • Оценщики подбирают модель к вашим данным: LogisticRegression и KNeighborsClassifier являются оценщиками sklearn.

Отчасти пайплайны настолько удивительны, так это их интуитивное использование согласованного API sklearn. Все, что вы можете сделать с трансформатором или оценщиком самостоятельно, вы можете сделать с конвейером. Это означает, что вы можете использовать такие методы, как .fit(), .transform() или .predict() в конвейере так же, как вы можете использовать для каждого отдельного элемента. Вы также можете использовать конвейер при перекрестной проверке для оценки производительности модели.

Как построить трубопровод?

При создании экземпляра класса конвейера sklearn основным параметром, который вам нужно определить, является steps, который принимает список преобразователей и средства оценки, которые вы хотите включить в конвейер. Каждый шаг записывается в виде кортежа. Первый элемент в кортеже - это string имя преобразователя / оценщика, а второй элемент - это сам преобразователь / оценщик.

Типичный конвейер может содержать несколько преобразователей и окончательный оценщик, но вам необязательно, чтобы в каждом конвейере было несколько компонентов. Фактически, простейший конвейер содержит только одно:

На практике трубопроводы обычно состоят как минимум из двух ступеней. В конечном итоге мы построим конвейер, состоящий из других конвейеров!

Чтобы построить конвейер, вам необходимо знать, какие шаги вы хотите предпринять для обработки данных и соответствия модели. Это может быть трудно полностью узнать заранее, поэтому иногда проще начать с беспорядочного кода, а затем провести его рефакторинг. Другая стратегия - сначала построить трубопроводы меньшего размера, чем собирать их вместе.

Труба по номерам

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

from sklearn.pipeline import Pipeline

# Pipeline for numerical data only
num_pipe = Pipeline(steps=[
    ('num_imputer', SimpleImputer(strategy='mean')),
    ('ss', StandardScaler()),
    ('logreg', LogisticRegression(random_state=42))
])

# Select only the numerical columns and drop all nulls
X_train_numerical = X_train.select_dtypes(include='float64')

# Fit and score the pipeline
num_pipe.fit(X_train_numerical, y_train)
num_score = num_pipe.score(X_train_numerical, y_train)
num_crossval_score = cross_val_score(num_pipe, X_train_numerical, y_train).mean()

# Compare scores
scores_table.append(['Numerical', num_score, num_crossval_score]) 
print(tabulate(scores_table, headers=scores_headers))
Dataset      Training score    Cross-val score
---------  ----------------  -----------------
Original           0.996364           0.992727
Numerical          0.989091           0.985455

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

Сравните синтаксис для выполнения обработки данных и подбора модели с конвейером и без него:

Без трубопровода:

imputer = SimpleImputer()
ss = StandardScaler()
logreg = LogisticRegression()

X_train_imp = imputer.fit_transform(X_train)
X_train_scl = ss.fit_transform(X_train_imp)
logreg.fit(X_train_scl)

С трубопроводом:

pipe = Pipeline(steps=[
    ('num_imputer', SimpleImputer()),
    ('ss', StandardScaler()),
    ('logreg', LogisticRegression())
])

pipe.fit(X_train)

Посмотрите, как использование конвейера уменьшило общий объем кода и полностью избавило от необходимости создавать новую переименованную версию X_train для каждого шага. Проще, с меньшим риском ошибок и очень СУХОЙ!

Категорический да

Можем ли мы добавить обратно в наши категориальные столбцы? Еще бы! Для этого мы подключим другой класс с именем ColumnTransformer. ColumnTransformer невероятно полезен именно в таких ситуациях, потому что он позволяет нам выполнять различные операции с разными столбцами за один раз.

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

# Sub-pipeline for the numerical columns
num_transformer = Pipeline(steps=[
                           ('num_imputer', SimpleImputer(strategy='mean')),
                           ('ss', StandardScaler())])

# Sub-pipeline for the categorical columns
cat_transformer = Pipeline(steps=[
                           ('cat_imputer', SimpleImputer(strategy='most_frequent')),
                           ('ohe', OneHotEncoder(handle_unknown='ignore'))])

Обратите внимание, что ни один из этих конвейеров не заканчивается нашим оценщиком LogisticRegression! Мы оставим это для нашего последнего конвейера. Вместо этого мы собираемся объединить эти два суб-конвейера вместе с помощью ColumnTransformer, который принимает список преобразователей, которые вы хотите включить в конвейер. Каждый преобразователь записан в виде тройки со следующими элементами:

  1. Название трансформатора (а string)
  2. Класс или экземпляр трансформатора или суб-конвейера.
  3. Столбцы для применения трансформатора к

Мы можем указать столбцы, указав список, например ['bill_length_mm', 'bill_depth_mm'], но использовать make_columns_selector проще, поскольку мы выбираем столбцы по типу данных, а не по имени.

from sklearn.compose import ColumnTransformer, make_column_selector

preprocessing = ColumnTransformer(
    transformers=[
        ('numerical sub-pipe', num_transformer, make_column_selector(dtype_include=['float64'])),
        ('categorical sub-pipe', cat_transformer, make_column_selector(dtype_include=['object']))
    ])

Собираем все вместе

Теперь мы можем создать полный конвейер, который предварительно обрабатывает все наши функции и заканчивается нашим оценщиком. Обратите внимание, что на этапе 'preprocessing' мы передаем ColumnTransformer, который содержит два суб-конвейера, а затем позволяем LogisticRegression творить чудеса со всем нашим полностью обработанным набором данных.

# A complete pipeline 
complete_pipe = Pipeline(steps=[
    ('preprocessing', preprocessing),
    ('logreg', LogisticRegression(random_state=42))
])

Полный конвейер теперь состоит из ColumnTransformer и классификатора LogisticRegression. Внутри ColumnTransformer есть два суб-конвейера, по одному для каждого типа данных в нашем наборе данных. Каждый суб-конвейер состоит из SimpleImputer и еще одного шага: StandardScaler для числовых данных и OneHotEncoder для категориальных данных.

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

# This will allow us to see a nice diagram of our pipeline
from sklearn import set_config
set_config(display='diagram')

complete_pipe

Теперь, когда мы хотим подогнать и оценить наш конвейер, нам не нужно выбирать столбцы или типы данных за его пределами - все это происходит внутри конвейера! Теперь мы можем передать X_train напрямую, без необходимости вручную преобразовывать его или вмешиваться в него.

# Fit and score the pipeline
complete_pipe.fit(X_train, y_train)
complete_score = complete_pipe.score(X_train, y_train)
complete_crossval_score = cross_val_score(complete_pipe, X_train, y_train).mean()

# Compare scores
scores_table.append(['Complete', complete_score, complete_crossval_score]) 
print(tabulate(scores_table, headers=scores_headers))
Dataset      Training score    Cross-val score
---------  ----------------  -----------------
Original           0.996364           0.992727
Numerical          0.989091           0.985455
Complete           0.996364           0.996364

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

Чтобы не повторяться…

… Но давайте еще раз взглянем на оба метода, чтобы увидеть весь конвейер целиком и полюбоваться мощью конвейера, который делает наш код проще, чище и как СУХОЙ статьей о конвейерах.

Без трубопроводов:

If you skipped over this code block before, this time try to identify each part that we included in our complete pipeline.
num_imputer = SimpleImputer(strategy='median')
cat_imputer = SimpleImputer(strategy='most_frequent')

X_train_num_imputed = num_imputer.fit_transform(X_train.select_dtypes(include=['int64', 'float64']))
X_train_cat_imputed = cat_imputer.fit_transform(X_train.select_dtypes(include='object'))

ss = StandardScaler()
X_train_num_imputed_scaled = ss.fit_transform(X_train_num_imputed)

ohe = OneHotEncoder(handle_unknown='ignore', sparse=False)
X_train_cat_imputed_ohe = ohe.fit_transform(X_train_cat_imputed)

X_train_preprocessed = np.concatenate([X_train_num_imputed_scaled, X_train_cat_imputed_ohe], axis=1)

logreg = LogisticRegression(random_state=42)
logreg.fit(X_train_preprocessed, y_train)
initial_score = logreg.score(X_train_preprocessed, y_train)
initial_crossval_score = cross_val_score(logreg, X_train_preprocessed, y_train).mean()

scores_table = [['Original', initial_score, initial_crossval_score]]
scores_headers = ['Dataset', 'Training score', 'Cross-val score']
print(tabulate(scores_table, headers=scores_headers))
Dataset      Training score    Cross-val score
---------  ----------------  -----------------
Original           0.996364           0.992727

Без трубопроводов мы должны:

  • выполнять каждый шаг вручную
  • отслеживать различные версии и расщепления X_train
  • соедините их все вместе в конце.

Он сложный, повторяющийся и подвержен высокому риску ошибок из-за опечаток или неправильного порядка действий. Не говоря уже об утечке данных (подсказка: это связано со StandardScaler!). И, что хуже всего, для того, чтобы оценить нашу модель на нашем удерживающем наборе, нам пришлось бы повторить весь процесс с совершенно новым набором версий и разделений X_test, плюс не забыть изменить каждый .fit_transform() на .transform() и полностью удалить logreg.fit() . Это рецепт бесконечной отладки и недействительных результатов. Нет, спасибо!

С трубопроводами:

Here's our pipeline, all in one go.
num_transformer = Pipeline(steps=[
                           ('num_imputer', SimpleImputer(strategy='mean')),
                           ('ss', StandardScaler())])

cat_transformer = Pipeline(steps=[
                           ('cat_imputer', SimpleImputer(strategy='most_frequent')),
                           ('ohe', OneHotEncoder(handle_unknown='ignore'))])

preprocessing = ColumnTransformer(
    transformers=[
        ('numerical sub-pipe', num_transformer, make_column_selector(dtype_include=['float64'])),
        ('categorical sub-pipe', cat_transformer, make_column_selector(dtype_include=['object']))
    ])

complete_pipe = Pipeline(steps=[
    ('preprocessing', preprocessing),
    ('logreg', LogisticRegression(random_state=42))
])

complete_pipe.fit(X_train, y_train)
complete_score = complete_pipe.score(X_train, y_train)
complete_crossval_score = cross_val_score(complete_pipe, X_train, y_train).mean()

scores_table.append(['Complete', complete_score, complete_crossval_score]) 
print(tabulate(scores_table, headers=scores_headers))
Dataset      Training score    Cross-val score
---------  ----------------  -----------------
Original           0.996364           0.992727
Complete           0.996364           0.996364

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

final_score = complete_pipe.score(X_test, y_test)
print('Final score on holdout set: ', final_score)
Final score on holdout set:  0.9855072463768116

Но подождите, это еще не все!

Если вы хотите повысить уровень своих конвейеров, попробуйте эти другие методы:

  • Добавляйте свои собственные функции с FunctionTransformer!
  • FeatureUnion для параллельного объединения трансформаторов!
  • Выполнение GridSearch на конвейере!

Надеюсь, это помогло вам перейти на использование конвейеров! Изучение конвейеров внесло ясность в мое понимание машинного обучения и внесло множество улучшений в мой код. Я все еще новичок в Data Science, поэтому не стесняйтесь оставлять предложения или (особенно) исправления в комментариях!

Удачного моделирования!

Посмотрите мой код и попробуйте сами: https://github.com/jmarkowi/build_a_pipeline