MemoryError occurs when Pipeline with GridSearchCV uses n_jobs=-1 on large datasets
0 reputation · 19 Nov 2020, 06:06 UTC
0 reputation · 19 Nov 2020, 06:06 UTC
Goal: Determine which transformer or estimator step dominates runtime in a scikit‑learn Pipeline before investing effort in hyperparameter tuning with GridSearchCV.
Constraint: scikit‑learn does not expose built‑in timers for individual Pipeline steps, and adding custom timing code may interfere with cross‑validation folds or parallel execution when n_jobs is set.
Uncertainty: Whether an external profiler can be safely applied inside a GridSearchCV loop without altering the fitted model or causing data leakage, and if the n_jobs parameter obscures per‑step measurements.
Questions: How can per‑step fit/transform times be measured reliably inside GridSearchCV? Does wrapping steps with a timing decorator introduce bias or leakage? Can step‑level timing be obtained when n_jobs > 1?
35775 reputation · 19 Nov 2020, 14:25 UTC
To reliably measure per‑step fit/transform time inside a GridSearchCV you must run the search sequentially (n_jobs=1) and wrap each pipeline step in a thin timing estimator that delegates unchanged to the original step. This adds negligible overhead, introduces no data leakage, and stores elapsed seconds on the wrapper instance for later inspection. When n_jobs>1 the worker processes do not return per‑step timings to the parent, so step‑level measurements are not available from the GridSearchCV result object.
n_jobs=-1 causes MemoryErrorWith n_jobs=-1 each CV fold runs in a separate worker process. Joblib serialises the entire training data (and any intermediate dense matrices produced by transformers) for every worker. On large datasets this duplicates memory many times over, leading to MemoryError. The problem is not the timing code but the data copying inherent to parallel CV.
from time import perf_counter
from sklearn.base import BaseEstimator, TransformerMixin, clone
class TimingWrapper(BaseEstimator, TransformerMixin):
"""Wrap any estimator/transformer and record fit/transform duration."""
def __init__(self, estimator):
self.estimator = estimator
self.fit_time_ = 0.0
self.transform_time_ = 0.0
def fit(self, X, y=None, **fit_params):
start = perf_counter()
self.estimator.fit(X, y, **fit_params)
self.fit_time_ = perf_counter() - start
return self
def transform(self, X):
start = perf_counter()
Xt = self.estimator.transform(X)
self.transform_time_ = perf_counter() - start
return Xt
# delegate other methods (predict, predict_proba, etc.)
def __getattr__(self, name):
return getattr(self.estimator, name)
Use it like:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
pipe = Pipeline([
("scaler", TimingWrapper(StandardScaler())),
("pca", TimingWrapper(PCA(n_components=50))),
("clf", TimingWrapper(LogisticRegression(max_iter=1000))),
])
n_jobs=1 and read fit_time_/transform_time_ from each wrapper.n_jobs=1. After fit, inspect cv_results_ for mean_fit_time and also pull the wrapper objects from best_estimator_.named_steps to see per‑step breakdown.best_params_ and held‑out predictions against an identical unwrapped pipeline; they must match exactly.If you need parallelism for a large grid, keep CV sequential inside each worker and parallelise over parameter candidates:
from joblib import Parallel, delayed
from sklearn.model_selection import ParameterGrid, cross_val_score
def eval_params(params, pipe, X, y, cv):
pipe.set_params(**params)
scores = cross_val_score(pipe, X, y, cv=cv, n_jobs=1) # sequential CV
return params, scores.mean(), pipe # pipe holds fitted wrappers with timings
param_grid = ParameterGrid({"pca__n_components": [30, 50, 70],
"clf__C": [0.1, 1, 10]})
results = Parallel(n_jobs=4)(
delayed(eval_params)(p, pipe, X, y, cv=5) for p in param_grid
)
Each worker returns a fitted pipeline whose wrappers contain per‑step times for that candidate. Peak memory stays low because only one CV fold runs per worker at a time.
tracemalloc or psutil.Process().memory_info().rss while varying n_jobs from -1 down to 1.GridSearchCV(..., n_jobs=2) often cuts memory enough.float32 dtype early (X = X.astype('float32')) and prefer sparse‑aware transformers (e.g., TruncatedSVD instead of dense PCA).IncrementalPCA, MiniBatchKMeans) when the dataset exceeds RAM.OMP_NUM_THREADS=1 / OPENBLAS_NUM_THREADS=1 during profiling to avoid nested thread‑pool contention.What are the approximate number of samples, number of features, and data dtype (float64/float32/sparse) of your training matrix? If the matrix is already dense float64 with >100 k rows × >1 k columns, the memory‑saving steps above become essential; if it is sparse or smaller, simply lowering n_jobs may suffice.
time.sleep(0.1) in transform) and confirm wrapper attributes report ~0.1 s per call.GridSearchCV(pipe, param_grid, cv=3, n_jobs=1) on a tiny grid; verify best_params_ and predictions match an unwrapped pipeline.joblib.Parallel candidate loop; ensure identical mean CV scores for each candidate compared to sequential run.n_jobs from -1 to 1; confirm the drop correlates with fewer data copies.Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.