Вступ: чому 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,RidgeClassifierCV(зsolver="svd") - Дискримінантний аналіз:
LinearDiscriminantAnalysis(зsolver="svd") - Декомпозиція:
PCA - Наївний Баєс:
GaussianNB - Кластеризація:
GaussianMixture - Калібрування:
CalibratedClassifierCV(зmethod="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})")
Розберімо, що тут відбувається крок за кроком:
- Завантаження — використовуємо стандартний датасет із OpenML
- Аналіз типів — розділяємо ознаки на три групи: числові, категоріальні з малою кардинальністю (менше 10 значень) і категоріальні з великою
- Три паралельні пайплайни препроцесингу — кожен тип даних обробляється по-своєму
- Відбір ознак —
SelectPercentileзmutual_info_classifзалишає 80% найінформативніших ознак - Класифікатор з оптимізацією порогу —
TunedThresholdClassifierCVавтоматично знаходить поріг, що максимізує F1 - Оцінка — стандартний
classification_reportна відкладеній вибірці - Крос-валідація — усі етапи від препроцесингу до оптимізації порогу перевіряються в кожному фолді
Ось що робить пайплайни такими потужними: весь ланцюжок обробки інкапсульований в одному об'єкті. При крос-валідації 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.
Ось короткий чеклист для старту з пайплайнами:
- Визначте типи ваших ознак (числові, категоріальні з низькою/високою кардинальністю, текстові)
- Створіть окремі трансформери для кожного типу
- Об'єднайте їх у
ColumnTransformer - Додайте відбір ознак та модель у
Pipeline - Налаштуйте гіперпараметри через
GridSearchCVабоRandomizedSearchCV - Оцініть через крос-валідацію
- Збережіть через
joblibдля deployment
Встановіть останню версію і спробуйте самі:
pip install scikit-learn==1.8.0
Хорошого вам машинного навчання!