Scikit-learn Pipeline cu ColumnTransformer și GridSearchCV: Ghid Complet 2026
Ghid complet pentru construirea pipeline-urilor scikit-learn 1.7 cu ColumnTransformer și GridSearchCV: exemple end-to-end, evitarea data leakage și sfaturi din producție.
Un scikit-learn Pipeline este un obiect care înlănțuie mai mulți pași de preprocesare și un estimator final într-un singur estimator compus, astfel încât întregul flux de antrenare și predicție să se execute ca o singură operație. Folosit împreună cu ColumnTransformer pentru a aplica transformări diferite pe coloane diferite și cu GridSearchCV pentru reglarea hiperparametrilor, Pipeline elimină scurgerea de date (data leakage), reduce codul boilerplate și face modelele reproducibile. În acest ghid arăt cum construiesc pipeline-uri solide pentru date tabulare mixte în scikit-learn 1.7, cu exemple complete și raționamentul statistic din spatele fiecărei decizii.
Pipeline înlănțuie transformeri și un estimator final, expunând un singur fit/predict; orice obiect cu fit_transform poate fi un pas intermediar.
ColumnTransformer aplică transformări diferite pe submulțimi de coloane (numerice, categorice, text), păstrând restul cu remainder='passthrough' sau eliminându-le.
GridSearchCV și RandomizedSearchCV caută hiperparametri folosind sintaxa nume_pas__parametru, evitând scurgerea prin re-antrenarea fiecărui pas pe fiecare fold.
Începând cu scikit-learn 1.5, set_config(transform_output="pandas") face ca pipeline-urile să returneze DataFrame-uri cu numele coloanelor păstrate, inestimabil pentru depanare.
Cea mai frecventă greșeală este apelarea fit_transform pe întregul dataset înainte de train_test_split; un Pipeline corect construit face acest lucru imposibil.
Ce este un Pipeline în scikit-learn?
Un Pipeline (sklearn.pipeline.Pipeline) este un meta-estimator care compune o secvență de pași. Fiecare pas intermediar trebuie să fie un transformer (să implementeze fit și transform), iar ultimul pas poate fi orice estimator (transformer sau predictor). Când apelezi pipeline.fit(X, y), scikit-learn antrenează fiecare pas pe ieșirea celui precedent; la pipeline.predict(X) aplică transform pe fiecare pas intermediar și predict pe ultimul.
Din perspectivă statistică, un pipeline implementează ceea ce literatura numește nested resampling atunci când e combinat cu validare încrucișată: parametrii fiecărui pas de preprocesare (media pentru imputare, deviațiile pentru standardizare, codificările pentru variabile categorice) sunt estimați doar pe partiția de antrenare a fiecărui fold. Fără pipeline, dezvoltatorii calculează adesea aceste statistici pe întregul dataset, ceea ce inflează artificial performanța estimată. E un fenomen documentat extensiv în ghidul oficial de capcane comune scikit-learn.
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
pipe = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
("clf", LogisticRegression(max_iter=1000)),
])
pipe.fit(X_train, y_train)
print("Acuratețe test:", pipe.score(X_test, y_test))
Observă cum un singur apel fit antrenează toți cei trei pași în ordine, iar score aplică automat imputarea și scalarea pe datele de test folosind statisticile învățate din datele de antrenare. Această proprietate, separarea strictă a fazei de fit de cea de transform, este miezul prevenirii data leakage.
Care este diferența dintre Pipeline și make_pipeline?
Diferența dintre Pipeline și make_pipeline este pur sintactică: ambele creează același tip de obiect, dar make_pipeline generează automat numele pașilor din numele clasei transformerului (în lowercase), în timp ce Pipeline îți cere să furnizezi explicit tuple (nume, transformer). Pentru pipeline-uri scurte și exploratorii, make_pipeline e mai concis. Pentru pipeline-uri pe care intenționezi să le reglezi cu GridSearchCV, recomand Pipeline pentru că numele explicite fac grilele de hiperparametri mult mai lizibile.
from sklearn.pipeline import make_pipeline
# Echivalent cu Pipeline-ul de mai sus, dar numele pașilor sunt
# auto-generate: 'simpleimputer', 'standardscaler', 'logisticregression'
pipe = make_pipeline(
SimpleImputer(strategy="median"),
StandardScaler(),
LogisticRegression(max_iter=1000),
)
print(pipe.named_steps)
În practică, eu folosesc make_pipeline pentru schițe rapide în Jupyter și trec la Pipeline de îndată ce articolul intră în reglare de hiperparametri sau salvare cu joblib. Onest, numele auto-generate devin un coșmar când ai două instanțe ale aceluiași transformer; make_pipeline le diferențiază cu sufixe numerice (simpleimputer-1, simpleimputer-2) care sunt greu de citit în log-uri la 2 dimineața.
ColumnTransformer pentru date tabulare mixte
ColumnTransformer este componenta care transformă pipeline-urile dintr-o jucărie didactică într-un instrument industrial. Datele tabulare reale conțin amestecuri eterogene: coloane numerice continue care au nevoie de standardizare, coloane categorice cu cardinalitate variabilă care necesită one-hot sau target encoding, coloane text care merg prin TF-IDF, și coloane de tip dată care trebuie descompuse în feature-uri ciclice. ColumnTransformer aplică un transformer diferit pe fiecare subset de coloane și concatenează rezultatele.
Două detalii care prind dezvoltatorii nepregătiți: remainder='drop' (implicit) elimină tăcut coloanele nelistate. Dacă scapi o coloană din numeric_features, ea dispare. Folosește remainder='passthrough' când vrei să păstrezi coloanele neatinse, sau make_column_selector(dtype_include=np.number) ca să selectezi coloanele după tip dinamic. În al doilea rând, parametrul handle_unknown="infrequent_if_exist" (disponibil din scikit-learn 1.3) grupează categoriile rare într-un bucket "infrequent", esențial pentru modele de producție care vor întâlni valori categorice nevăzute la inferență.
Exemplu complet end-to-end: clasificare pe date eterogene
Iată pipeline-ul complet pe care îl folosesc ca șablon pentru majoritatea proiectelor de clasificare tabulară. Combină ColumnTransformer cu un estimator gradient boosting și demonstrează salvarea modelului pentru deployment. Dacă vii din ecosistemul pandas, vezi și ghidul meu despre curățarea și preprocesarea datelor cu pandas pentru pașii care preced antrenarea modelului.
import joblib
import pandas as pd
from sklearn import set_config
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
set_config(transform_output="pandas") # Pastreaza numele coloanelor
df = pd.read_parquet("clienti.parquet")
X, y = df.drop(columns=["churn"]), df["churn"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42,
)
preprocessor = ColumnTransformer([
("num", Pipeline([
("imp", SimpleImputer(strategy="median")),
("sc", StandardScaler()),
]), make_column_selector(dtype_include="number")),
("cat", Pipeline([
("imp", SimpleImputer(strategy="most_frequent")),
("oh", OneHotEncoder(handle_unknown="infrequent_if_exist",
sparse_output=False)),
]), make_column_selector(dtype_include=["object", "category"])),
], verbose_feature_names_out=False)
model = Pipeline([
("prep", preprocessor),
("clf", HistGradientBoostingClassifier(
max_iter=300, learning_rate=0.05, random_state=42,
)),
])
scores = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc")
print(f"ROC-AUC CV: {scores.mean():.3f} +/- {scores.std():.3f}")
model.fit(X_train, y_train)
print("ROC-AUC test:", model.score(X_test, y_test))
joblib.dump(model, "churn_model.joblib")
Structura asta rezolvă mai multe probleme deodată: make_column_selector selectează automat coloanele numerice și categorice (deci adăugarea unei noi coloane nu necesită modificarea pipeline-ului), set_config(transform_output="pandas") face ca toate ieșirile intermediare să fie DataFrame-uri (mult mai ușor de depanat), iar întregul model serializat cu joblib conține preprocesarea. La inferență în producție pasezi pur și simplu un DataFrame brut și obții predicții.
Reglarea hiperparametrilor cu GridSearchCV
GridSearchCV caută exhaustiv într-o grilă de combinații de hiperparametri, antrenând o nouă instanță a pipeline-ului pentru fiecare combinație × fold de validare încrucișată. Sintaxa cheie este nume_pas__parametru (două underscore-uri): permite să indexezi orice parametru al oricărui pas, inclusiv parametri imbricați ai sub-pipeline-urilor dintr-un ColumnTransformer.
Pentru spații de căutare mari, RandomizedSearchCV este aproape întotdeauna o alegere mai bună decât GridSearchCV. Lucrarea fundamentală a lui Bergstra și Bengio (2012), "Random Search for Hyper-Parameter Optimization", demonstrează matematic că eșantionarea aleatorie acoperă spațiul mai eficient decât grilele atunci când doar câțiva hiperparametri sunt cu adevărat importanți, situație tipică în practică. Pentru optimizare bayesiană, recomand scikit-optimize sau Optuna ca biblioteci complementare.
Cum eviți data leakage cu pipeline-uri?
Eviți data leakage construind fiecare pas de preprocesare ca un transformer într-un Pipeline, astfel încât parametrii săi (mediana pentru imputare, media și deviația pentru standardizare, vocabularul pentru one-hot) să fie învățați doar din partiția de antrenare. Regula de aur: nicio statistică care depinde de date nu trebuie calculată în afara pipeline-ului. Principiul ăsta e analizat în profunzime în articolul "Leakage in Data Mining" de Kaufman et al. (2012), care a inventariat zeci de proiecte Kaggle compromise de scurgere subtilă.
Sincer, am fost prins de bug-ul ăsta chiar acum vreo doi ani, când livram un model de scoring pentru un client. Cross-validation arăta 0.91 ROC-AUC, iar producția dădea 0.78. Cauza? Un fit_transform rătăcit pe scaler, înainte de split. De atunci, regula mea e: dacă pre-procesarea nu trăiește într-un Pipeline, presupun că am o scurgere.
Anti-pattern-ul clasic arată așa:
# GRESIT - scalare inainte de split scurge informatia din test in train
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X) # foloseste media/std din TOT setul
X_train, X_test = train_test_split(X_scaled) # prea tarziu
Versiunea corectă, idiomatică:
# CORECT - split-ul vine primul, scaler-ul invata doar din train
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
pipe = Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression())])
pipe.fit(X_train, y_train) # scaler-ul vede DOAR X_train
pipe.score(X_test, y_test) # transform pe X_test foloseste statisticile din train
În proiectele mele de cercetare, aplic o regulă mai dură: orice pre-procesare care nu poate fi exprimată ca un transformer scikit-learn trebuie izolată într-un FunctionTransformer sau o clasă custom care moștenește din BaseEstimator, TransformerMixin. Dacă nu poți, atunci preprocesul respectiv probabil scurge date.
Transformatori personalizați și FunctionTransformer
Pentru transformări simple fără stare, FunctionTransformer împachetează o funcție Python într-un transformer compatibil pipeline. Pentru transformări cu stare (care necesită fit pentru a învăța statistici), moștenește din BaseEstimator și TransformerMixin:
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.preprocessing import FunctionTransformer
# Varianta 1: fara stare - logaritm + 1 pentru date strict pozitive
log_tr = FunctionTransformer(np.log1p, feature_names_out="one-to-one")
# Varianta 2: cu stare - winsorizer la percentilele 1 si 99
class Winsorizer(BaseEstimator, TransformerMixin):
def __init__(self, lower=0.01, upper=0.99):
self.lower = lower
self.upper = upper
def fit(self, X, y=None):
X = np.asarray(X)
self.lower_ = np.quantile(X, self.lower, axis=0)
self.upper_ = np.quantile(X, self.upper, axis=0)
return self
def transform(self, X):
return np.clip(X, self.lower_, self.upper_)
def get_feature_names_out(self, input_features=None):
return np.asarray(input_features, dtype=object)
Două detalii statistice de reținut: cuantilele din Winsorizer.fit sunt estimate doar pe X_train (mulțumită Pipeline-ului), iar get_feature_names_out e necesar dacă vrei ca set_config(transform_output="pandas") să păstreze numele coloanelor. Pentru o introducere mai blândă în transformările matematice pe DataFrame-uri, ghidul meu despre pandas GroupBy și tabele pivot tratează metodele echivalente native pandas.
Noutăți în scikit-learn 1.7: set_output și meta-routing
Scikit-learn 1.7 (lansat în 2026) consolidează două caracteristici care îmbunătățesc dramatic ergonomia pipeline-urilor. API-ul set_output (introdus în 1.2, stabilizat în 1.6) permite configurarea formatului de ieșire per transformer, fie "pandas", fie "polars", fără a strica integrarea NumPy. Beneficiul concret: poți inspecta orice pas intermediar ca DataFrame.
from sklearn import set_config
set_config(transform_output="pandas")
# Sau, pentru un singur transformer:
preprocessor.set_output(transform="polars")
# Inspecteaza iesirea preprocessor-ului ca DataFrame:
X_transformed = model.named_steps["prep"].transform(X_train.head())
print(X_transformed.dtypes)
A doua caracteristică, metadata routing, rezolvă o problemă veche: cum trimiți argumente per-eșantion (sample_weight, groups) prin Pipeline către estimatorii care le acceptă. Sintaxa explicită cu set_fit_request elimină ghicirea. Vezi notele de lansare scikit-learn 1.7 pentru detalii complete.
După șase ani de troubleshooting pipeline-uri în producție, văd aceleași cinci greșeli repetându-se. Le enumăr aici cu antidotul fiecăreia.
Imputare în afara pipeline-ului.df.fillna(df.mean()) înainte de split este cea mai comună sursă de scurgere. Soluție: SimpleImputer în pipeline, întotdeauna.
OneHotEncoder fără handle_unknown. Implicit, encoder-ul aruncă o excepție pe categorii nevăzute. Folosește handle_unknown="infrequent_if_exist" sau "ignore" pentru robustețe în producție.
Standardizare pe variabile categorice.StandardScaler aplicat pe coloane one-hot distruge interpretabilitatea. Izolează scalarea în branch-ul numeric al ColumnTransformer.
Reglare cu cross_val_score pe model deja antrenat.cross_val_score clonează estimatorul intern; antrenarea prealabilă este ignorată dar consumă timp. Pasează modelul ne-antrenat.
Salvare cu pickle în loc de joblib. Pickle nu garantează compatibilitatea între versiuni scikit-learn. joblib este recomandat oficial pentru obiecte care conțin array-uri NumPy mari. Vezi documentația oficială despre persistența modelelor.
Pentru a duce pipeline-urile la nivelul următor, adică orchestrarea mai multor modele și versionarea seturilor de date, recomand explorarea Polars ca substitut pentru pandas în pre-procesare. Ghidul meu despre Polars în Python arată cum biblioteca se integrează cu scikit-learn 1.7 prin set_output(transform="polars"), oferind un câștig de 5 până la 10× la viteză pe seturi mari.
Întrebări frecvente
Pot folosi un Pipeline scikit-learn cu XGBoost sau LightGBM?
Da. Atât XGBoost (xgboost.XGBClassifier) cât și LightGBM (lightgbm.LGBMClassifier) implementează API-ul scikit-learn, deci pot fi pasul final într-un Pipeline. Hiperparametrii lor se referențiază identic prin nume_pas__parametru în GridSearchCV.
Cum extrag numele coloanelor după ColumnTransformer cu OneHotEncoder?
Apelează preprocessor.get_feature_names_out() după fit. Alternativ, setează set_config(transform_output="pandas") global și toate ieșirile vor fi DataFrame-uri cu numele coloanelor păstrate, inclusiv numele expandate ale variabilelor one-hot.
Care este diferența dintre Pipeline și ColumnTransformer?
Pipeline aplică pașii secvențial pe aceleași coloane; ColumnTransformer aplică transformeri paraleli pe coloane diferite și concatenează rezultatele. În practică, le combini: ColumnTransformer ca primul pas al unui Pipeline, urmat de estimator.
Pot folosi GridSearchCV pentru a regla și parametrii ColumnTransformer-ului?
Da. Folosește calea completă cu duble underscore: "prep__num__imp__strategy" selectează parametrul strategy al pasului imp din sub-pipeline-ul num al ColumnTransformer-ului numit prep.
Trebuie să fac scalarea înainte sau după one-hot encoding?
De obicei nici una. Aplici StandardScaler doar pe coloanele numerice și OneHotEncoder doar pe cele categorice, prin branch-uri separate ale ColumnTransformer. Excepție: modelele lineare cu regularizare L1/L2 beneficiază de scalarea variabilelor one-hot dacă cardinalitatea diferă mult între coloane.
Cum salvez și încarc un Pipeline pentru producție?
Folosește joblib.dump(pipe, "model.joblib") și joblib.load("model.joblib"). Fixează versiunea scikit-learn în requirements.txt, modelele salvate nu sunt garantate compatibile între versiuni majore. Pentru deployment cross-version, consideră formatul ONNX prin sklearn-onnx.
Ghid practic pentru analiza seriilor temporale în Python cu Pandas 3.0. Învață DatetimeIndex, resample(), rolling(), ewm(), shift() și decompoziție sezonieră cu exemple funcționale de cod.
Învață cum să folosești groupby(), pivot_table(), crosstab() și melt() în Pandas 3.0. Ghid practic cu agregări cu nume, transform, filter și sfaturi de performanță pentru seturi mari de date.
Ghid practic pentru curățarea datelor în Python cu Pandas 3.0: gestionarea valorilor lipsă, eliminarea duplicatelor, detectarea outlierilor, curățarea textului și pipeline-uri de preprocesare reutilizabile cu pipe().