машинное обучение
June 13, 2022

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

Как известно, некоторые признаки, характеризующие объекты в моделях машинного обучения могут оказаться фиктивными или избыточными. Соответственно, их включение в пайплайн может привести к снижению качества прогнозов. Рассмотрим, один из популярных способов оптимизации набора признаков, который заключается в формировании их множества путем последовательного добавления по одному наиболее эффективному.
То есть сначала работа модели тестируется на каждом отдельном признаке и выбирается максимизирующий выбранную оценку, затем добавляется еще один, такой что на паре модель дает лучшую оценку и так далее до достижения заданного количества признаков. Существует схожий алгоритм, но действующий в обратном порядке - когда из множества всех признаков убирается по одному, от потери которого качество меньше всего "пострадает".
Оба алгоритма реализованы в классе SequentialFeatureSelector модуля sklearn.feature_selection. Рассмотрим его работу на примере следующего датафрейма:

from sklearn.datasets import load_diabetes
from sklearn.metrics import mean_absolute_error, mean_absolute_percentage_error
import pandas as pd
import numpy as np
np.random.seed(0)

df, y = load_diabetes(return_X_y=True, as_frame=True)
df['age'] = pd.cut(df['age'], bins=5, labels=False)
df['target'] = y
df = df.sample(frac=1).reset_index(drop=True)
display(df.head())
display(df.shape)

Разделим данные на выборки:

from sklearn.model_selection import train_test_split

X_tr, X_val, y_tr, y_val = train_test_split(df.drop(columns='target').copy(), 
                                          df['target'], test_size=0.2)
y_tr.shape[0], y_val.shape[0]

А теперь сформируем пайплайн нашей модели (подробнее читай здесь):

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import RobustScaler
from sklearn.preprocessing import OneHotEncoder
from sklearn.linear_model import ElasticNet

ct = ColumnTransformer([('age_enc', OneHotEncoder(sparse=False, handle_unknown='ignore'), 
                         ['age'])], remainder='passthrough')
model = Pipeline([('enc', ct), ('sc', RobustScaler()), ('reg', ElasticNet())])

model.fit(X_tr, y_tr)

y_p = model.predict(X_val)

print(f'{mean_absolute_error(y_val, y_p):.2f}',
      f'{mean_absolute_percentage_error(y_val, y_p):.3f}')

Попытка запустить SequentialFeatureSelector с нашим пайплайном приведет к ошибке. Обнаружилось, что класс SequentialFeatureSelector не корректно работает с ColumnTransformer, поэтому первый шаг заменим на собственную обработку и пересоздадим объект model:

ohe = OneHotEncoder(sparse=False, handle_unknown='ignore')
ohe.fit(X_tr[['age']])
ages_tr = ohe.transform(X_tr[['age']])
ages_val =  ohe.transform(X_val[['age']])

X_tr_mod = np.concatenate([X_tr.drop(columns='age'), ages_tr], axis=1)
X_val_mod = np.concatenate([X_val.drop(columns='age'), ages_val], axis=1)

model = Pipeline([('sc', RobustScaler()), ('reg', ElasticNet())])

Теперь обучим SequentialFeatureSelector:

from sklearn.feature_selection import SequentialFeatureSelector

sfs = SequentialFeatureSelector(model, n_features_to_select=5, cv=5,
                          scoring='neg_mean_absolute_percentage_error', 
                          direction='forward', n_jobs=-1) 

sfs.fit(X_tr_mod, y_tr)

В конструктор класса передаем объект модели, количество оптимальных признаков (n_features_to_select) и итераций в кросс-валидации (cv), метрику (scoring), направление (direction, определяет будет происходить добавление или удаление признаков), количество потоков обработки.

Метод get_support возвращает маску выбранных признаков, где True соответствует попавшим в итоговое множество::

sfs.get_support()

Теперь можно запустить пайплайн на отобранных признаках:

model.fit(X_tr_mod[:, sfs.get_support()], y_tr)

y_p = model.predict(X_val_mod[:, sfs.get_support()])

print(f'{mean_absolute_error(y_val, y_p):.2f}',
      f'{mean_absolute_percentage_error(y_val, y_p):.3f}')

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

best_score = 0.5
best_num = 0

for num in range(1, X_tr_mod.shape[1]-1):
    sfs = SequentialFeatureSelector(model, n_features_to_select=num, cv=5,
                          scoring='neg_mean_absolute_percentage_error', 
                          direction='forward', n_jobs=-1) 

    sfs.fit(X_tr_mod, y_tr)
    model.fit(X_tr_mod[:, sfs.get_support()], y_tr)
    y_p = model.predict(X_val_mod[:, sfs.get_support()])
    score_temp = mean_absolute_percentage_error(y_val, y_p)
    print(f'Признаков {num}, mape={score_temp}')
    if score_temp<best_score:
      best_score = score_temp
      best_num = num
      best_support = sfs.get_support()

print(f"best mape = {best_score}")
print(f"best num = {best_num}")
best_support

...

Теперь оставим оптимальное количество признаков и оценим модель:

from sklearn.feature_selection import SequentialFeatureSelector

sfs = SequentialFeatureSelector(model, n_features_to_select=9, cv=5,
                          scoring='neg_mean_absolute_percentage_error', 
                          direction='forward', n_jobs=-1) 

sfs.fit(X_tr_mod, y_tr)

model.fit(X_tr_mod[:, sfs.get_support()], y_tr)

y_p = model.predict(X_val_mod[:, sfs.get_support()])

print(f'{mean_absolute_error(y_val, y_p):.2f}',
      f'{mean_absolute_percentage_error(y_val, y_p):.3f}')