Сучасні ML-пайплайни з scikit-learn 1.8: від підготовки даних до GPU-прискорення

Покроковий посібник зі створення ML-пайплайнів у scikit-learn 1.8: Pipeline, ColumnTransformer, TargetEncoder, GPU-прискорення через Array API, Metadata Routing, оптимізація порогів та наскрізний приклад класифікації.

Вступ: чому ML-пайплайни — це не розкіш, а необхідність

Якщо ви вже читали наші посібники з pandas 3.0 чи Polars, то знаєте, як завантажувати, очищувати та трансформувати дані. Але що далі? Дані підготовлені, DataFrame блищить чистотою — і тут починається найцікавіше: машинне навчання.

І ось саме тут більшість проєктів перетворюються на спагеті-код із десятками глобальних змінних, ручним відстеженням порядку трансформацій та класичним "ой, забув застосувати scaler до тестових даних". Знайомо, правда?

Рішення — ML-пайплайни (pipelines). У scikit-learn пайплайн — це не просто зручна обгортка, а повноцінний інструмент, який гарантує коректне відтворення всіх етапів обробки від сирих даних до фінальної моделі. А з виходом scikit-learn 1.8 (реліз 10 грудня 2025 року) ми отримали ще більше причин будувати свої ML-проєкти саме так: розширена підтримка Array API для GPU-обчислень, нові естіматори, покращення продуктивності лінійних моделей до 10x і підтримка free-threaded CPython 3.14.

У цій статті ми пройдемо весь шлях — від базового Pipeline до просунутих патернів із вкладеними трансформерами, GPU-прискоренням через CuPy та PyTorch, метаданих маршрутизації й оптимізації порогів класифікації. Усе з реальними прикладами коду, які можна запустити одразу. Отже, поїхали.

Основи Pipeline: ланцюжок трансформацій

Давайте почнемо з фундаменту. sklearn.pipeline.Pipeline — це послідовність кроків, де кожен крок (крім останнього) є трансформером (реалізує fit і transform), а останній крок може бути будь-яким естіматором — класифікатором, регресором, чим завгодно.

from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression

# Спосіб 1: явне іменування кроків
pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('pca', PCA(n_components=10)),
    ('clf', LogisticRegression(max_iter=1000))
])

# Спосіб 2: автоматичне іменування через make_pipeline
pipe = make_pipeline(
    StandardScaler(),
    PCA(n_components=10),
    LogisticRegression(max_iter=1000)
)

# Тепер весь ланцюжок виконується одним викликом
pipe.fit(X_train, y_train)
score = pipe.score(X_test, y_test)

Чому це краще за ручне виконання кроків? Є три ключові причини:

  • Захист від витоку даних (data leakage) — при крос-валідації кожен фолд коректно навчає трансформери лише на тренувальних даних
  • Відтворюваність — зберігши пайплайн через joblib.dump(), ви отримаєте весь ланцюжок обробки, а не лише модель
  • Чистота коду — замість десяти змінних X_scaled, X_pca, X_encoded у вас один об'єкт

Коли ви викликаєте pipe.fit(X_train, y_train), scikit-learn послідовно виконує fit_transform для кожного кроку (крім останнього) і передає результат наступному. Для останнього кроку викликається просто fit. При predict усі проміжні кроки виконують transform, а останній — predict.

Різниця між Pipeline і make_pipeline доволі проста: перший вимагає явного іменування кроків (кортежі ('name', estimator)), другий генерує імена автоматично з назв класів у нижньому регістрі. Для швидких експериментів make_pipeline зручніший. Але коли вам потрібно звертатися до конкретних кроків — скажімо, для налаштування гіперпараметрів — явні імена роблять код набагато зрозумілішим.

Корисний прийом — доступ до окремих кроків пайплайну через named_steps:

# Доступ до навченого PCA після fit
pipe.fit(X_train, y_train)
pca_step = pipe.named_steps['pca']
print(f"Пояснена дисперсія: {pca_step.explained_variance_ratio_.sum():.2%}")

# Або через індекс
scaler_step = pipe[0]  # перший крок — StandardScaler

ColumnTransformer: коли дані неоднорідні

У реальних проєктах дані рідко бувають однорідними. Зазвичай у вас є числові колонки, категоріальні, іноді текстові — і кожен тип потребує своєї обробки. Саме для цього й існує ColumnTransformer.

from sklearn.compose import ColumnTransformer, make_column_transformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline

# Визначаємо колонки за типом
numeric_features = ['age', 'income', 'credit_score']
categorical_features = ['city', 'education', 'job_type']

# Окремий пайплайн для числових даних
numeric_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

# Окремий пайплайн для категоріальних даних
categorical_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

# Об'єднуємо в ColumnTransformer
preprocessor = ColumnTransformer([
    ('num', numeric_transformer, numeric_features),
    ('cat', categorical_transformer, categorical_features)
])

# Фінальний пайплайн: препроцесинг + модель
full_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000))
])

full_pipeline.fit(X_train, y_train)

Для швидкого прототипування є скорочений варіант — make_column_transformer:

preprocessor = make_column_transformer(
    (StandardScaler(), numeric_features),
    (OneHotEncoder(handle_unknown='ignore'), categorical_features),
    remainder='passthrough'  # інші колонки залишаємо без змін
)

Зверніть увагу на параметр remainder: за замовчуванням він дорівнює 'drop', тобто колонки, які не вказані явно, просто відкидаються. Хочете зберегти їх — ставте 'passthrough'. Це, до речі, одна з найчастіших помилок у новачків, тому запам'ятайте цей момент.

Автоматичний вибір колонок за типом

Замість ручного переліку колонок можна використовувати make_column_selector:

from sklearn.compose import make_column_selector

preprocessor = ColumnTransformer([
    ('num', numeric_transformer, make_column_selector(dtype_include='number')),
    ('cat', categorical_transformer, make_column_selector(dtype_include='object'))
])

Це особливо зручно при роботі з DataFrames, де стовпці мають визначені типи. І тут є нюанс: якщо ви перейшли на pandas 3.0 з його новим рядковим типом str замість object, не забудьте оновити селектори — dtype_include='object' більше не перехопить рядкові колонки автоматично. Ми про це детально писали в посібнику з міграції на pandas 3.0.

Сучасний препроцесинг: TargetEncoder та інші інструменти

TargetEncoder: розумне кодування категорій

TargetEncoder, доданий у scikit-learn 1.3, — це потужна альтернатива OneHotEncoder для категоріальних ознак з високою кардинальністю (тобто з безліччю унікальних значень). Замість створення сотень бінарних колонок, він замінює кожну категорію числом на основі середнього значення цільової змінної.

from sklearn.preprocessing import TargetEncoder

# TargetEncoder змішує глобальне середнє з умовним середнім для категорії
encoder = TargetEncoder(smooth='auto')

# Важливо: fit_transform(X, y) != fit(X, y).transform(X)
# fit_transform використовує крос-фітинг для уникнення перенавчання
X_encoded = encoder.fit_transform(X_train[['city']], y_train)

Ключова особливість полягає в тому, що TargetEncoder використовує згладжування (smoothing): він змішує глобальне середнє цільової змінної із середнім, обумовленим конкретною категорією. Це запобігає перенавчанню на рідкісних категоріях. Параметр smooth='auto' застосовує емпіричну баєсівську оцінку дисперсії для автоматичного визначення оптимального згладжування.

І ще одна важлива деталь, яку варто підкреслити: fit_transform(X, y) не дорівнює fit(X, y).transform(X). У fit_transform використовується схема крос-фітингу (подібно до K-Fold), щоб уникнути перенавчання на тренувальних даних. Тонкий момент, але критично важливий.

FunctionTransformer: свої трансформації в один рядок

Коли потрібна проста кастомна трансформація, не варто писати цілий клас — вистачить FunctionTransformer:

from sklearn.preprocessing import FunctionTransformer
import numpy as np

# Логарифмічна трансформація для правосторонньо скошених розподілів
log_transformer = FunctionTransformer(
    func=np.log1p,           # log(1 + x) — безпечно для нулів
    inverse_func=np.expm1,   # зворотна трансформація
    validate=True
)

# Можна вставити в пайплайн як будь-який інший трансформер
pipe = make_pipeline(
    log_transformer,
    StandardScaler(),
    LogisticRegression()
)

StandardScaler та Array API у scikit-learn 1.8

У версії 1.8 StandardScaler отримав підтримку Array API, а це означає можливість працювати безпосередньо з CuPy та PyTorch тензорами на GPU. Детальніше про це — у розділі про GPU-прискорення нижче. Але вже зараз варто знати: якщо ваші дані вже на GPU, StandardScaler не потребуватиме перенесення їх назад на CPU. Зручно, правда?

Просунуті патерни: FeatureUnion та кастомні трансформери

FeatureUnion: паралельна обробка ознак

Якщо ColumnTransformer розділяє колонки для різної обробки, то FeatureUnion робить дещо інше — він застосовує різні трансформації до одних і тих самих даних і об'єднує результати горизонтально:

from sklearn.pipeline import FeatureUnion
from sklearn.decomposition import PCA, TruncatedSVD

# Поєднуємо PCA та SVD ознаки з одного набору даних
combined_features = FeatureUnion([
    ('pca', PCA(n_components=5)),
    ('svd', TruncatedSVD(n_components=3))
])

pipe = make_pipeline(
    StandardScaler(),
    combined_features,
    LogisticRegression()
)

На практиці FeatureUnion часто комбінують із ColumnTransformer: спочатку різні колонки обробляються окремо, а потім до об'єднаного результату можна застосувати паралельні трансформації. Це дає змогу створювати дуже виразні конвеєри обробки ознак, де одні й ті самі дані проходять через кілька "гілок".

Кастомні трансформери: повний контроль

Для складніших трансформацій доведеться створити свій клас, наслідуючи BaseEstimator та TransformerMixin:

from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np

class OutlierClipper(BaseEstimator, TransformerMixin):
    """Обрізає викиди за межами заданих перцентилів."""

    def __init__(self, lower_percentile=1, upper_percentile=99):
        self.lower_percentile = lower_percentile
        self.upper_percentile = upper_percentile

    def fit(self, X, y=None):
        # Обчислюємо границі на тренувальних даних
        self.lower_bound_ = np.percentile(X, self.lower_percentile, axis=0)
        self.upper_bound_ = np.percentile(X, self.upper_percentile, axis=0)
        return self

    def transform(self, X):
        # Обрізаємо значення за обчисленими границями
        X_clipped = np.clip(X, self.lower_bound_, self.upper_bound_)
        return X_clipped

# Використовуємо у пайплайні
pipe = make_pipeline(
    OutlierClipper(lower_percentile=2, upper_percentile=98),
    StandardScaler(),
    LogisticRegression()
)

Наслідування BaseEstimator дає автоматичні get_params() і set_params() — це критично важливо для роботи з GridSearchCV. А TransformerMixin автоматично створює fit_transform на основі ваших fit та transform. Не потрібно писати нічого зайвого.

Вкладені пайплайни

Пайплайни можна вкладати один в одний, створюючи складні, але цілком читабельні ланцюжки:

from sklearn.feature_selection import SelectKBest, f_classif

# Вкладений пайплайн: препроцесинг -> відбір ознак -> модель
inner_pipeline = Pipeline([
    ('preprocessor', preprocessor),          # ColumnTransformer зверху
    ('selector', SelectKBest(f_classif, k=20)),
    ('classifier', LogisticRegression(max_iter=1000))
])

Налаштування гіперпараметрів: подвійне підкреслення — ваш найкращий друг

Одна з найпотужніших можливостей пайплайнів — безшовна інтеграція з GridSearchCV та RandomizedSearchCV. Завдяки нотації з подвійним підкресленням (__) можна налаштовувати параметри будь-якого кроку пайплайну, навіть глибоко вкладеного.

from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.ensemble import RandomForestClassifier
from scipy.stats import randint, uniform

# Створюємо пайплайн
pipe = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(random_state=42))
])

# Параметри для пошуку: ім'я_кроку__параметр
param_grid = {
    'preprocessor__num__imputer__strategy': ['mean', 'median'],
    'classifier__n_estimators': [100, 200, 500],
    'classifier__max_depth': [5, 10, 20, None],
    'classifier__min_samples_split': [2, 5, 10]
}

# GridSearchCV з крос-валідацією
grid_search = GridSearchCV(
    pipe,
    param_grid,
    cv=5,
    scoring='f1_weighted',
    n_jobs=-1,
    verbose=1
)

grid_search.fit(X_train, y_train)
print(f"Найкращі параметри: {grid_search.best_params_}")
print(f"Найкращий F1: {grid_search.best_score_:.4f}")

Зверніть увагу на синтаксис: preprocessor__num__imputer__strategy — це шлях крізь вкладену структуру. Спочатку заходимо в крок preprocessor (наш ColumnTransformer), потім у трансформер num, потім в крок imputer всередині нього, і нарешті — параметр strategy. Трохи нагадує навігацію по файловій системі, чи не так?

Для великих просторів параметрів краще використовувати RandomizedSearchCV з розподілами замість фіксованих списків. У GridSearchCV з нашим прикладом потрібно перевірити 2 × 3 × 4 × 3 = 72 комбінації (і кожна — з 5-fold CV, тобто 360 тренувань). RandomizedSearchCV дозволяє обмежити кількість спроб параметром n_iter:

param_distributions = {
    'classifier__n_estimators': randint(50, 500),
    'classifier__max_depth': randint(3, 30),
    'classifier__min_samples_leaf': randint(1, 20),
    'classifier__max_features': uniform(0.1, 0.9)
}

random_search = RandomizedSearchCV(
    pipe,
    param_distributions,
    n_iter=50,          # кількість випадкових комбінацій
    cv=5,
    scoring='f1_weighted',
    n_jobs=-1,
    random_state=42
)

random_search.fit(X_train, y_train)

TunedThresholdClassifierCV: оптимізація порогів для бізнес-метрик

За замовчуванням класифікатори scikit-learn використовують поріг 0.5 для бінарної класифікації: якщо predict_proba > 0.5 — позитивний клас, інакше — негативний. Але в реальному бізнесі цей поріг рідко буває оптимальним.

Чесно кажучи, вартість хибнопозитивного результату (false positive) майже завжди сильно відрізняється від вартості хибнонегативного (false negative). Уявіть собі: пропустити шахрайську транзакцію та помилково заблокувати легітимну — це зовсім різні наслідки.

TunedThresholdClassifierCV, доданий у scikit-learn 1.5, вирішує цю проблему елегантно — він знаходить оптимальний поріг прийняття рішення через крос-валідацію:

from sklearn.model_selection import TunedThresholdClassifierCV
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import fbeta_score, make_scorer

# Базовий класифікатор
base_clf = GradientBoostingClassifier(n_estimators=200, random_state=42)

# Оптимізуємо поріг під balanced_accuracy
tuned_clf = TunedThresholdClassifierCV(
    base_clf,
    scoring='balanced_accuracy',
    cv=5,
    store_cv_results=True
)

tuned_clf.fit(X_train, y_train)

print(f"Оптимальний поріг: {tuned_clf.best_threshold_:.4f}")
print(f"Balanced accuracy: {tuned_clf.best_score_:.4f}")

# Предикт уже використовує оптимальний поріг
y_pred = tuned_clf.predict(X_test)

Це особливо корисно у задачах виявлення шахрайства, медичної діагностики чи кредитного скорингу — скрізь, де ціна помилки асиметрична. Можна використовувати будь-яку метрику: 'f1', 'precision', 'recall', або навіть кастомний скорер.

І що важливо — TunedThresholdClassifierCV чудово працює всередині пайплайну як фінальний крок:

full_pipe = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', TunedThresholdClassifierCV(
        GradientBoostingClassifier(random_state=42),
        scoring='f1',
        cv=5
    ))
])

Array API та GPU-прискорення в scikit-learn 1.8

А тепер — до найгарячішого. Мабуть, однією з головних тем scikit-learn останніх версій стала підтримка Python Array API, що дозволяє передавати дані у форматі CuPy чи PyTorch тензорів і виконувати обчислення безпосередньо на GPU. У версії 1.8 список підтримуваних естіматорів суттєво розширився.

Що підтримується в 1.8

Ось основні естіматори та функції з підтримкою Array API:

  • Препроцесинг: StandardScaler, MinMaxScaler, MaxAbsScaler, Normalizer, PolynomialFeatures
  • Лінійні моделі: Ridge, RidgeCV, RidgeClassifier, RidgeClassifierCVsolver="svd")
  • Дискримінантний аналіз: LinearDiscriminantAnalysissolver="svd")
  • Декомпозиція: PCA
  • Наївний Баєс: GaussianNB
  • Кластеризація: GaussianMixture
  • Калібрування: CalibratedClassifierCVmethod="temperature")
  • Метрики: accuracy_score, confusion_matrix, roc_curve, f1_score, pairwise_distances та десятки інших
  • Мета-естіматори: GridSearchCV, RandomizedSearchCV, cross_val_predict

Увімкнення Array API dispatch

Для використання GPU-прискорення потрібно два кроки. Спочатку встановити змінну середовища перед імпортом scipy та scikit-learn:

import os
os.environ["SCIPY_ARRAY_API"] = "1"

import sklearn
from sklearn import config_context

Потім обгорнути виклики в контекстний менеджер або встановити глобальну конфігурацію:

import cupy as cp
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
from sklearn.pipeline import make_pipeline
from sklearn import config_context

# Переносимо дані на GPU
X_gpu = cp.asarray(X_train)
y_gpu = cp.asarray(y_train)

# Увімкнення Array API dispatch
with config_context(array_api_dispatch=True):
    pipe = make_pipeline(StandardScaler(), Ridge(solver='svd'))
    pipe.fit(X_gpu, y_gpu)

    # Прогнозування теж на GPU
    X_test_gpu = cp.asarray(X_test)
    predictions = pipe.predict(X_test_gpu)

# Результат — CuPy масив на GPU
print(type(predictions))  # <class 'cupy.ndarray'>

Використання з PyTorch

import torch
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn import config_context

# Конвертуємо дані в PyTorch тензори на GPU
X_torch = torch.tensor(X_train, dtype=torch.float32, device='cuda')
y_torch = torch.tensor(y_train, dtype=torch.float32, device='cuda')

with config_context(array_api_dispatch=True):
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X_torch)

    pca = PCA(n_components=10)
    X_pca = pca.fit_transform(X_scaled)

# Дані залишаються на GPU
print(X_pca.device)  # cuda:0

Ключове правило Array API в scikit-learn — "все слідує за X". Якщо вхідний масив X — це CuPy масив на GPU, то всі внутрішні обчислення та результати також будуть CuPy масивами на GPU. Не потрібно нічого вручну переносити туди-сюди — бібліотека сама розбереться.

У релізних нотатках 1.8 повідомляється про прискорення до 10x при використанні GPU порівняно з одним ядром CPU (тести на Google Colab). Звісно, реальний приріст залежить від розміру даних, типу операцій та конкретного GPU, але все одно це вражаючий результат.

Обмеження

Варто пам'ятати: не всі алгоритми підходять для GPU-прискорення через Array API. Деревоподібні моделі (DecisionTree, RandomForest, GradientBoosting) принципово не є матричними алгоритмами — їхня Cython-реалізація не може бути ефективно перенесена на GPU через цей механізм. Для GPU-прискорення дерев дивіться на RAPIDS cuML або XGBoost із GPU-підтримкою.

Metadata Routing: нова система передачі метаданих

Уявіть ситуацію: у вас є зважений датасет, де кожному зразку присвоєна вага (sample_weight). Ви хочете, щоб і модель при навчанні, і скорер при оцінці враховували ці ваги. Раніше це було, м'яко кажучи, болісним процесом — доводилось вручну передавати ваги на кожному кроці. Нова система Metadata Routing нарешті вирішує цю проблему.

import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_validate
from sklearn.metrics import make_scorer, f1_score

# Увімкнення маршрутизації метаданих
sklearn.set_config(enable_metadata_routing=True)

# Створюємо скорер, що приймає sample_weight
weighted_f1 = make_scorer(f1_score, response_method='predict')
weighted_f1 = weighted_f1.set_score_request(sample_weight=True)

# Створюємо модель, що приймає sample_weight при fit
lr = LogisticRegression(max_iter=1000)
lr = lr.set_fit_request(sample_weight=True)

# Тепер cross_validate передасть ваги і моделі, і скореру
cv_results = cross_validate(
    lr,
    X_train,
    y_train,
    scoring=weighted_f1,
    cv=5,
    params={'sample_weight': sample_weights}   # передаємо один раз
)

Як це працює? Метод set_fit_request(sample_weight=True) повідомляє системі, що цей естіматор очікує sample_weight при виклику fit. Аналогічно, set_score_request(sample_weight=True) — для скорера. Мета-естіматори на кшталт cross_validate і GridSearchCV автоматично маршрутизують метадані до всіх компонентів, які їх запросили.

Це працює і в пайплайнах — метадані прозоро передаються крізь усі кроки. Ось приклад:

import sklearn
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV

sklearn.set_config(enable_metadata_routing=True)

# Пайплайн, де останній крок потребує sample_weight
pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('clf', LogisticRegression(max_iter=1000).set_fit_request(sample_weight=True))
])

# GridSearchCV автоматично передасть ваги до LogisticRegression
grid = GridSearchCV(pipe, param_grid={'clf__C': [0.1, 1.0, 10.0]}, cv=5)
grid.fit(X_train, y_train, sample_weight=sample_weights)

Ключова перевага — декларативність. Замість того, щоб вручну прокидувати параметри через кожен рівень вкладеності, ви один раз оголошуєте, хто що потребує, і система сама розбирається з маршрутизацією. У складних пайплайнах із вкладеними ColumnTransformer та мета-естіматорами це економить купу нервів і коду.

Практичний приклад: повний ML-пайплайн від А до Я

Досить теорії — зберімо все разом у реалістичному прикладі. Побудуємо повноцінний пайплайн класифікації на датасеті Adult Census (прогнозування доходу >50K/$). Цей датасет ідеально підходить для демонстрації, бо містить і числові, і категоріальні ознаки з різною кардинальністю, пропущені значення та незбалансовані класи.

import numpy as np
import pandas as pd
from sklearn.datasets import fetch_openml
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.preprocessing import (
    StandardScaler, OneHotEncoder, TargetEncoder, FunctionTransformer
)
from sklearn.impute import SimpleImputer
from sklearn.feature_selection import SelectPercentile, mutual_info_classif
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import TunedThresholdClassifierCV
from sklearn.metrics import (
    classification_report, f1_score,
    precision_score, recall_score
)

# === 1. Завантаження даних ===
data = fetch_openml('adult', version=2, as_frame=True)
X = data.data
y = (data.target == '>50K').astype(int)

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

print(f"Тренувальна вибірка: {X_train.shape}")
print(f"Тестова вибірка: {X_test.shape}")

# === 2. Визначаємо типи ознак ===
numeric_features = X.select_dtypes(include='number').columns.tolist()
# Низька кардинальність — OneHotEncoder
low_card_features = [
    col for col in X.select_dtypes(include='category').columns
    if X[col].nunique() < 10
]
# Висока кардинальність — TargetEncoder
high_card_features = [
    col for col in X.select_dtypes(include='category').columns
    if X[col].nunique() >= 10
]

print(f"Числові: {numeric_features}")
print(f"Категоріальні (низька кард.): {low_card_features}")
print(f"Категоріальні (висока кард.): {high_card_features}")

# === 3. Будуємо препроцесор ===
numeric_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler', StandardScaler())
])

low_card_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

high_card_transformer = Pipeline([
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('encoder', TargetEncoder(smooth='auto'))
])

preprocessor = ColumnTransformer([
    ('num', numeric_transformer, numeric_features),
    ('low_cat', low_card_transformer, low_card_features),
    ('high_cat', high_card_transformer, high_card_features)
])

# === 4. Повний пайплайн із відбором ознак та оптимізацією порогу ===
full_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('feature_selection', SelectPercentile(
        mutual_info_classif, percentile=80
    )),
    ('classifier', TunedThresholdClassifierCV(
        GradientBoostingClassifier(
            n_estimators=200,
            max_depth=5,
            learning_rate=0.1,
            random_state=42
        ),
        scoring='f1',
        cv=5
    ))
])

# === 5. Навчання ===
full_pipeline.fit(X_train, y_train)

# === 6. Оцінка ===
y_pred = full_pipeline.predict(X_test)
print("\n=== Результати на тестовій вибірці ===")
print(classification_report(y_test, y_pred))

# Подивимося оптимальний поріг
tuned_clf = full_pipeline.named_steps['classifier']
print(f"Оптимальний поріг рішення: {tuned_clf.best_threshold_:.4f}")

# === 7. Крос-валідація всього пайплайну ===
cv_scores = cross_val_score(
    full_pipeline, X_train, y_train,
    cv=5, scoring='f1', n_jobs=-1
)
print(f"\nКрос-валідація F1: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")

Розберімо, що тут відбувається крок за кроком:

  1. Завантаження — використовуємо стандартний датасет із OpenML
  2. Аналіз типів — розділяємо ознаки на три групи: числові, категоріальні з малою кардинальністю (менше 10 значень) і категоріальні з великою
  3. Три паралельні пайплайни препроцесингу — кожен тип даних обробляється по-своєму
  4. Відбір ознакSelectPercentile з mutual_info_classif залишає 80% найінформативніших ознак
  5. Класифікатор з оптимізацією порогуTunedThresholdClassifierCV автоматично знаходить поріг, що максимізує F1
  6. Оцінка — стандартний classification_report на відкладеній вибірці
  7. Крос-валідація — усі етапи від препроцесингу до оптимізації порогу перевіряються в кожному фолді

Ось що робить пайплайни такими потужними: весь ланцюжок обробки інкапсульований в одному об'єкті. При крос-валідації scikit-learn коректно навчає препроцесор, відбирає ознаки та оптимізує поріг окремо для кожного фолду. Жодного витоку даних — і це без зайвих зусиль з вашого боку.

Якщо хочете ще покращити результати, легко додати GridSearchCV поверх цього пайплайну:

from sklearn.model_selection import GridSearchCV

# Шукаємо оптимальні гіперпараметри всередині пайплайну
param_grid = {
    'feature_selection__percentile': [60, 80, 100],
    'classifier__estimator__n_estimators': [100, 200, 300],
    'classifier__estimator__max_depth': [3, 5, 7],
    'classifier__scoring': ['f1', 'balanced_accuracy']
}

grid = GridSearchCV(
    full_pipeline, param_grid,
    cv=3, scoring='f1', n_jobs=-1
)
grid.fit(X_train, y_train)
print(f"Найкраща комбінація: {grid.best_params_}")

Зверніть увагу, як через подвійне підкреслення ми звертаємося навіть до параметрів вкладеного естіматора: classifier__estimator__n_estimators — це n_estimators у GradientBoostingClassifier, який загорнуто в TunedThresholdClassifierCV, який є кроком classifier у головному пайплайні. Три рівні вкладеності — і все працює без жодних проблем.

HDBSCAN: потужна кластеризація з коробки

Ще одна приємна подія: алгоритм HDBSCAN (Hierarchical Density-Based Spatial Clustering of Applications with Noise), який раніше жив у пакеті scikit-learn-contrib, тепер входить до основної бібліотеки (з версії 1.3). Це чудовий алгоритм для кластеризації даних із кластерами різної щільності — і, на мою думку, один із найзручніших інструментів для дослідницького аналізу.

from sklearn.cluster import HDBSCAN
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# HDBSCAN прекрасно працює в пайплайні
clustering_pipe = make_pipeline(
    StandardScaler(),
    HDBSCAN(min_cluster_size=50, min_samples=10)
)

# fit_predict повертає мітки кластерів (-1 для шуму)
labels = clustering_pipe.fit_predict(X)

n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
n_noise = (labels == -1).sum()
print(f"Знайдено кластерів: {n_clusters}")
print(f"Шумових точок: {n_noise}")

На відміну від KMeans, HDBSCAN не вимагає вказувати кількість кластерів заздалегідь — він визначає її автоматично на основі щільності даних. Основні параметри: min_cluster_size — мінімальна кількість точок для формування кластера, та min_samples — параметр, що контролює "консервативність" алгоритму. Чим більше min_samples, тим більше точок потрапить у категорію шуму, але й кластери будуть надійнішими.

Покращення продуктивності в scikit-learn 1.8

Окремо хочу виділити суттєві покращення швидкості у версії 1.8 — деякі з них справді вражають:

  • Gap Safe Screening Rules для Lasso/ElasticNet — прискорення до 10x завдяки ранньому виключенню нерелевантних ознак під час координатного спуску. Якщо у вас датасет із тисячами ознак — це реальна різниця між хвилинами й секундами.
  • DecisionTreeRegressor з criterion="absolute_error" — складність знижена з O(n²) до O(n log n). На 100 000 зразках один спліт тепер займає ~100 мс замість ~20 секунд. Відчуваєте різницю?
  • Free-threaded CPython 3.14 — scikit-learn 1.8 постачає free-threaded wheels, що дозволяє ефективніше використовувати n_jobs > 1 з threading-бекендом замість multiprocessing.
  • Temperature scaling у CalibratedClassifierCV — новий метод method="temperature" для калібрування ймовірностей, особливо ефективний у мультикласових задачах (один вільний параметр замість одного на клас).
from sklearn.calibration import CalibratedClassifierCV
from sklearn.svm import SVC

# Temperature scaling — новинка 1.8
calibrated_svc = CalibratedClassifierCV(
    SVC(),
    method='temperature',  # замість 'sigmoid' або 'isotonic'
    ensemble=False
)

calibrated_svc.fit(X_train, y_train)
probas = calibrated_svc.predict_proba(X_test)

Найкращі практики та антипатерни

За час роботи з ML-пайплайнами (а це вже чимало проєктів) ми зібрали набір рекомендацій, що допоможуть уникнути типових помилок.

Робіть так

  • Весь препроцесинг — у пайплайн. Без винятків. Навіть проста стандартизація повинна бути частиною пайплайну, інакше ви гарантовано отримаєте витік даних при крос-валідації.
  • Використовуйте set_output(transform='pandas') — починаючи з scikit-learn 1.2, трансформери можуть повертати DataFrames замість numpy-масивів, зберігаючи імена колонок. Для дебагу це надзвичайно зручно.
  • Зберігайте пайплайни, а не моделі. joblib.dump(pipeline, 'model.pkl') зберігає весь ланцюжок обробки. При деплойменті вам не потрібно відтворювати препроцесинг окремо.
  • Тестуйте на маленькій вибірці перед запуском на повному датасеті. Простий pipe.fit(X_train[:100], y_train[:100]) зекономить вам годину очікування, якщо щось налаштовано неправильно.
  • Документуйте архітектуру — складний пайплайн із вкладеними трансформерами важко читати. Використовуйте pipe.get_params() та HTML-візуалізацію в Jupyter.

Не робіть так

  • Не застосовуйте трансформації до поділу на train/test. Це антипатерн номер один. Якщо ви зробили StandardScaler().fit_transform(X) до train_test_split — ваші метрики на тесті завищені, бо скейлер "бачив" тестові дані.
  • Не ігноруйте remainder='drop' в ColumnTransformer. За замовчуванням колонки, які ви не вказали, тихо відкидаються. Це може стати неприємним сюрпризом.
  • Не забувайте про handle_unknown='ignore' в OneHotEncoder. Якщо в тестових даних з'явиться нова категорія, без цього параметра пайплайн впаде з помилкою.
  • Не навчайте TargetEncoder поза пайплайном. Нагадаю: fit_transform та fit().transform() у TargetEncoder дають різні результати. Усередині пайплайну scikit-learn коректно використовує fit_transform для тренувальних даних.
  • Не використовуйте Array API з деревоподібними моделями. Це не прискорить їх — алгоритмічно вони не є матричними операціями. Array API допомагає лінійним моделям, PCA, метрикам.

Швидка порада: візуалізація пайплайну

from sklearn import set_config

# Увімкнення HTML-діаграми пайплайну в Jupyter
set_config(display='diagram')

# Просто виведіть пайплайн — отримаєте інтерактивну діаграму
full_pipeline

У scikit-learn 1.8 HTML-представлення стало ще кращим: параметри відображаються з тултіпами й посиланнями на документацію. Ідеальний інструмент для код-рев'ю та презентацій.

Інтеграція з pandas 3.0 та Copy-on-Write

Якщо ви вже мігрували на pandas 3.0 (а якщо ні — у нас є посібник саме для цього), то варто знати: нова поведінка Copy-on-Write чудово сумісна з пайплайнами scikit-learn. Раніше деякі трансформери могли випадково модифікувати вхідний DataFrame через неявні views. З CoW кожна модифікація створює копію, що робить поведінку передбачуванішою.

import pandas as pd

# pandas 3.0: Copy-on-Write увімкнено за замовчуванням
df = pd.DataFrame({
    'age': [25, 30, 35, 40],
    'income': [50000, 60000, 70000, 80000],
    'city': ['Kyiv', 'Lviv', 'Kyiv', 'Odesa']
})

# Пайплайн не змінить оригінальний DataFrame
pipe.fit(df[['age', 'income']], y)
# df залишається недоторканим — CoW гарантує це

Також зверніть увагу: у pandas 3.0 рядкові колонки тепер мають тип str замість object. Якщо ваші make_column_selector використовували dtype_include='object', їх потрібно оновити на dtype_include='string' або dtype_include=str. Маленька зміна, яка може зламати весь пайплайн, якщо її пропустити.

Серіалізація та розгортання пайплайнів

Пайплайн, який не можна зберегти й розгорнути — це, по суті, просто красивий експеримент. Для серіалізації scikit-learn пайплайнів є два основні підходи:

import joblib
from sklearn.pipeline import Pipeline

# Зберігаємо весь пайплайн в один файл
joblib.dump(full_pipeline, 'production_model.pkl')

# Завантажуємо в production-середовищі
loaded_pipeline = joblib.load('production_model.pkl')

# Прогноз одразу готовий — препроцесинг включений
prediction = loaded_pipeline.predict(new_data)

Важливий нюанс: якщо ваш пайплайн містить кастомні трансформери (як наш OutlierClipper), модуль із цим класом має бути доступний при десеріалізації. Тому тримайте кастомні трансформери в окремому модулі, який можна імпортувати і в тренувальному, і в production-середовищі.

Для більш надійної серіалізації з контролем версій розгляньте skops.io — бібліотеку від команди scikit-learn, що забезпечує безпечнішу альтернативу pickle/joblib з підтримкою перевірки типів при завантаженні.

Висновок

Scikit-learn 1.8 — це не просто черговий реліз із мінорними фіксами. Це суттєвий крок у напрямку сучасних ML-практик:

  • Array API нарешті робить GPU-прискорення в scikit-learn практичною реальністю. Список підтримуваних естіматорів росте з кожною версією — і це не може не тішити.
  • Metadata Routing вирішує давню проблему передачі ваг та інших метаданих через складні ланцюжки обробки.
  • Покращення продуктивності — прискорення Lasso/ElasticNet до 10x та DecisionTree з absolute_error — це не мікрооптимізації, а принципові зміни для реальних проєктів.
  • Free-threaded CPython 3.14 відкриває двері для ефективнішого паралелізму без GIL.

Пайплайни — це не просто "красивий" спосіб організації коду. Це, по суті, єдиний правильний спосіб будувати ML-системи, які можна відтворити, протестувати й розгорнути в продакшені. Якщо ви досі тренуєте моделі окремо від препроцесингу — 2026 рік — ідеальний час, щоб змінити підхід.

Комбінуючи потужність scikit-learn пайплайнів з ефективною роботою з даними через pandas 3.0 чи Polars, ви отримуєте повний стек для промислового ML на Python — від завантаження сирих даних до розгортання моделі. І тепер — із можливістю GPU-прискорення без виходу за межі звичного API.

Ось короткий чеклист для старту з пайплайнами:

  1. Визначте типи ваших ознак (числові, категоріальні з низькою/високою кардинальністю, текстові)
  2. Створіть окремі трансформери для кожного типу
  3. Об'єднайте їх у ColumnTransformer
  4. Додайте відбір ознак та модель у Pipeline
  5. Налаштуйте гіперпараметри через GridSearchCV або RandomizedSearchCV
  6. Оцініть через крос-валідацію
  7. Збережіть через joblib для deployment

Встановіть останню версію і спробуйте самі:

pip install scikit-learn==1.8.0

Хорошого вам машинного навчання!

Про Автора Editorial Team

Our team of expert writers and editors.