Инструменты: scikit-learn

scikit-learn.org

Стандартом де-факто для классического машинного обучения в Python является библиотека scikit-learn (sklearn), построенная поверх NumPy, SciPy и Pandas и охватывающая весь конвейер обработки данных.

датасет → разбиение → предобработка → обучение → метрики → подбор гиперпараметров → сохранение модели

NumPy и Pandas рассмотрены в главе NumPy и pandas, построение графиков — в главе Визуализация на Python. В настоящей главе конвейер рассматривается по шагам на одном сквозном примере — классификации сортов вина по химическому составу.

Установка библиотеки осуществляется следующей командой.

pip install scikit-learn

Единый API и методы fit / predict / transform

Основной причиной популярности scikit-learn является единообразный интерфейс. Все объекты библиотеки делятся на два типа.

  • Модели (estimators) обучаются предсказывать целевую величину; к ним относятся KNeighborsClassifier, LinearRegression, RandomForestClassifier, MLPClassifier...
  • Преобразователи (transformers) подготавливают входные данные; к ним относятся StandardScaler, OneHotEncoder, PCA...

Объекты обоих типов имеют одинаковый набор методов.

  • fit(X, y) обучает объект: модель подбирает параметры, а преобразователь запоминает статистики, вычисленные по выборке (например, среднее и дисперсию каждого признака);
  • predict(X) предсказывает метки для новых объектов, которых модель не видела;
  • predict_proba(X) предсказывает вероятности, приписываемые классам (у классификаторов);
  • transform(X) преобразует входные данные (у преобразователей);
  • fit_transform(X) обучает и преобразует одним вызовом.

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

Используемые библиотеки

Импортируем всё, что потребуется в этой главе.

import numpy as np
import pandas as pd

from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV, StratifiedKFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
    accuracy_score,
    precision_score,
    recall_score,
    f1_score,
    confusion_matrix,
    classification_report,
    roc_auc_score,
)

Датасеты

В sklearn.datasets выделяются три семейства функций.

  • load_* возвращает небольшие учебные датасеты, входящие в состав библиотеки, такие как load_iris, load_wine, load_breast_cancer, load_digits;
  • fetch_* возвращает большие датасеты, загружаемые из интернета при первом обращении, такие как fetch_california_housing, fetch_openml;
  • make_* представляет собой генераторы синтетических данных для экспериментов, такие как make_classification, make_regression, make_blobs.

Рассмотрим датасет Wine, содержащий 178 образцов вина трёх сортов (три класса), каждый из которых описан 13 числовыми признаками химического состава: содержание алкоголя, яблочной кислоты, магния, фенолов, интенсивность цвета и так далее. Задача заключается в определении сорта винограда по химическому анализу.

# as_frame=True возвращает данные в виде pandas.DataFrame
wine = load_wine(as_frame=True)

data = wine.frame          # признаки + целевая переменная в одной таблице
X = wine.data              # матрица объекты-признаки, shape (178, 13)
y = wine.target            # метки классов: 0, 1, 2

print(data.shape)          # размерность
print(wine.target_names)   # названия сортов
data.head()                # первые пять строк: данные всегда просматриваются вручную

Разбиение выборки через train_test_split

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

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,       # 20% объектов — в тест
    random_state=42,     # фиксируем случайность для воспроизводимости
    stratify=y,          # сохраняем соотношение классов в обеих частях
)

Параметр stratify=y важен при дисбалансе классов, поскольку гарантирует совпадение долей классов в обучающей и тестовой выборках. Отложенная тестовая выборка не используется до финальной оценки: она представляет данные, которых модель не видела.

Предобработка

Масштабирование признаков

Как показано в главе про классические алгоритмы, методы, опирающиеся на расстояния (kNN, k-means) и на градиентный спуск (линейные модели, нейронные сети), чувствительны к масштабу признаков. В датасете Wine магний измеряется десятками, а фенолы единицами, поэтому признаки, подаваемые модели, необходимо стандартизировать.

scaler = StandardScaler()

# обучаем scaler ТОЛЬКО на обучающей выборке...
X_train_scaled = scaler.fit_transform(X_train)

# ...а тест лишь преобразуем уже выученными статистиками
X_test_scaled = scaler.transform(X_test)

StandardScaler приводит каждый признак к нулевому среднему и единичной дисперсии; среди альтернатив — MinMaxScaler (нормализация, приводящая признак к диапазону [0, 1]) и RobustScaler (устойчивый к выбросам).

fit вызывается только на обучающей выборке. Если обучить scaler на всей выборке, статистики тестовой части попадут в обучение, что называется утечкой данных (data leakage) и приводит к завышению оценок качества.

Кодирование категориальных признаков

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

from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

# OneHotEncoder: каждая категория -> отдельный бинарный признак
# подходит для номинальных признаков без порядка
encoder = OneHotEncoder(handle_unknown="ignore")

# OrdinalEncoder: категории -> целые числа 0, 1, 2, ...
# подходит для порядковых признаков (плохо/хорошо/отлично)
ordinal = OrdinalEncoder()

Для применения разных преобразований к разным столбцам (масштабирования числовых и кодирования категориальных) используется ColumnTransformer из sklearn.compose.

Обучение модели

Обучим два классификатора из главы про классические алгоритмы — метод ближайших соседей и случайный лес.

# kNN: голосование K ближайших соседей, требует масштабирования
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train_scaled, y_train)

# случайный лес: ансамбль деревьев, к масштабу нечувствителен
forest = RandomForestClassifier(n_estimators=200, random_state=42)
forest.fit(X_train, y_train)

# предсказания на тестовой выборке
y_pred_knn = knn.predict(X_test_scaled)
y_pred_forest = forest.predict(X_test)

Обучение сводится к одному вызову fit, а гиперпараметры модели (число соседей n_neighbors, число деревьев n_estimators) задаются в конструкторе.

Pipeline

Пара scaler + модель встречается настолько часто, что для неё предусмотрен отдельный класс Pipeline, объединяющий цепочку преобразований и финальную модель в один объект с тем же API.

model = Pipeline(steps=[
    ("scaler", StandardScaler()),          # шаг 1: стандартизация
    ("knn", KNeighborsClassifier(n_neighbors=5)),  # шаг 2: классификатор
])

# fit сам обучит scaler на train и передаст преобразованные данные в kNN
model.fit(X_train, y_train)

# predict сам преобразует тест выученным scaler'ом и предскажет
y_pred = model.predict(X_test)

Pipeline решает сразу три задачи.

  • код становится короче, а вероятность ошибки в нём уменьшается;
  • исключается утечка данных: преобразователи, стоящие в цепочке, всегда обучаются только на обучающей выборке;
  • весь собранный конвейер можно передать в кросс-валидацию и подбор гиперпараметров как единое целое, а затем сохранить одним файлом.

Метрики качества

Классификация

Теория метрик рассмотрена в разделе про логистическую регрессию; здесь вызываются готовые функции.

print(f"Accuracy:  {accuracy_score(y_test, y_pred):.3f}")

# у нас три класса, поэтому указываем способ усреднения:
# average="macro" — среднее по классам без учёта их размера
print(f"Precision: {precision_score(y_test, y_pred, average='macro'):.3f}")
print(f"Recall:    {recall_score(y_test, y_pred, average='macro'):.3f}")
print(f"F1:        {f1_score(y_test, y_pred, average='macro'):.3f}")

# матрица ошибок: строки — истинные классы, столбцы — предсказанные
print(confusion_matrix(y_test, y_pred))

# сводный отчёт по всем метрикам для каждого класса
print(classification_report(y_test, y_pred, target_names=wine.target_names))

Напомним смысл метрик.

  • Accuracy представляет собой долю правильных ответов и вводит в заблуждение при дисбалансе классов;
  • Precision показывает, какая доля объектов, отнесённых к положительным, действительно положительна;
  • Recall показывает, какую долю действительно положительных объектов модель обнаружила;
  • F1 представляет собой гармоническое среднее Precision и Recall;
  • ROC-AUC — площадь под ROC-кривой, то есть качество ранжирования объектов по вероятностям.

ROC-AUC вычисляется по предсказанным вероятностям, а не по меткам; для многоклассовой задачи указывается стратегия «один против остальных».

y_proba = model.predict_proba(X_test)   # вероятности классов, shape (n, 3)
print(f"ROC-AUC: {roc_auc_score(y_test, y_proba, multi_class='ovr'):.3f}")

Регрессия

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

import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

# отдельные имена, чтобы не затереть сквозной пример с вином
X_reg, y_reg = load_diabetes(return_X_y=True)
X_reg_tr, X_reg_te, y_reg_tr, y_reg_te = train_test_split(
    X_reg, y_reg, test_size=0.2, random_state=42)

regressor = Ridge(alpha=1.0).fit(X_reg_tr, y_reg_tr)
y_pred = regressor.predict(X_reg_te)

mae = mean_absolute_error(y_reg_te, y_pred)   # средняя абсолютная ошибка
mse = mean_squared_error(y_reg_te, y_pred)    # среднеквадратичная ошибка
rmse = np.sqrt(mse)                       # корень из MSE, в единицах величины
r2 = r2_score(y_reg_te, y_pred)               # коэффициент детерминации R^2

print(f"MAE:  {mae:.1f}")
print(f"RMSE: {rmse:.1f}")
print(f"R^2:  {r2:.3f}")
MAE:  46.1
RMSE: 55.5
R^2:  0.419
  • MAE более устойчива к выбросам и измеряется в тех же единицах, что и целевая величина;
  • MSE сильнее штрафует крупные ошибки (квадратичный штраф);
  • R² показывает, какую долю дисперсии данных объясняет модель, причём 1 соответствует идеальной модели, 0 — предсказанию средним, а значения меньше 0 — результату хуже среднего.

Кросс-валидация

Одно разбиение train/test даёт одну случайную оценку качества, поэтому более надёжной является кросс-валидация. Выборка делится на \(k\) частей (фолдов), модель \(k\) раз обучается на \(k-1\) частях и проверяется на оставшейся, а полученные оценки усредняются.

# стратифицированные фолды сохраняют соотношение классов
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# передаём Pipeline целиком — предобработка честно обучается внутри каждого фолда
scores = cross_val_score(model, X_train, y_train, cv=cv, scoring="f1_macro")

print(f"F1 по фолдам: {scores}")
print(f"Среднее: {scores.mean():.3f} +- {scores.std():.3f}")
F1 по фолдам: [0.89355742 1.         0.96658312 0.92951496 0.96658312]
Среднее: 0.951 +- 0.036

Разброс оценок по фолдам не менее важен, чем среднее, поскольку большая дисперсия является признаком нестабильной модели или слишком малой выборки.

Подбор гиперпараметров через GridSearchCV

Гиперпараметры (число соседей \(K\), глубина деревьев, коэффициент регуляризации) не выучиваются моделью, а подбираются отдельно. GridSearchCV перебирает все комбинации из заданной сетки, оценивая каждую кросс-валидацией.

param_grid = {
    "knn__n_neighbors": [1, 3, 5, 7, 9, 15],   # имя шага в Pipeline + "__" + имя параметра
    "knn__weights": ["uniform", "distance"],   # обычное или взвешенное голосование
}

search = GridSearchCV(
    model,                  # наш Pipeline: scaler + kNN
    param_grid,
    cv=cv,                  # схема кросс-валидации
    scoring="f1_macro",     # метрика для сравнения комбинаций
    n_jobs=-1,              # задействовать все ядра процессора
)

search.fit(X_train, y_train)

print(f"Лучшие параметры: {search.best_params_}")
print(f"Лучший CV F1: {search.best_score_:.3f}")

# лучшая модель уже переобучена на всём train — финальная проверка на тесте
best_model = search.best_estimator_
y_pred = best_model.predict(X_test)
print(f"F1 на тесте: {f1_score(y_test, y_pred, average='macro'):.3f}")
Лучшие параметры: {'knn__n_neighbors': 15, 'knn__weights': 'uniform'}
Лучший CV F1: 0.959
F1 на тесте: 1.000

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

Сохранение модели через joblib

Обученную модель не требуется переобучать при каждом запуске: её сохраняют на диск. Для sklearn-моделей рекомендуется joblib, работающий эффективнее pickle на объектах, содержащих большие NumPy-массивы.

import joblib

# сохраняем весь Pipeline: и scaler, и модель, одним файлом
joblib.dump(best_model, "wine_knn.joblib")

# ... позже, в другом скрипте или сервисе
loaded = joblib.load("wine_knn.joblib")
prediction = loaded.predict(X_test)   # сырые признаки — предобработка внутри

Модели необходимо загружать только из доверенных источников: joblib-файл, как и pickle, при загрузке может выполнить произвольный код. Кроме того, необходимо фиксировать версию scikit-learn среди зависимостей проекта, как описано в главе «От скрипта к приложению», поскольку модель, сохранённая одной версией библиотеки, не обязательно загрузится другой.

Дальнейшее изучение

Таким образом, конвейер — датасет, разбиение, стандартизация, обучение через единый API, Pipeline, метрики и кросс-валидация, подбор гиперпараметров, сохранение модели — является одним и тем же для любой задачи; меняются только данные и модель.

Готовые ноутбуки-рецепты по всем основным моделям, включая линейную и логистическую регрессию, деревья и случайный лес, наивный Байес, kNN, SVM, k-means, DBSCAN, PCA и нейронные сети прямого распространения, собраны в отдельном сборнике рецептов по машинному обучению. Каждый рецепт построен единообразно: по цепочке от библиотеки к датасету, предобработке, обучению, метрикам и графикам.