Sam Austinon October 24, 202618 min read

Tuning a Scikit-learn Pipeline Beyond GridSearchCV

Tuning a Scikit-learn Pipeline Beyond GridSearchCV
Contents

Figure 1: Tuning a Scikit-learn Pipeline Beyond GridSearchCV — a grid stops where your guesses stop

You set up a neat grid, hit run, and wait. Twenty minutes later, you get a best score that sits at the edge of your grid. Did the search find the best settings, or did your grid simply stop too soon?

GridSearchCV works fine for small problems, but it breaks down fast. This guide shows you how to tune a scikit-learn pipeline beyond grid search, with randomized search, successive halving, model swapping, multi-metric scoring, and nested cross-validation. I ran every example, so the results below are real.

Grid search tries every combination you list. That approach explodes quickly. Five values for each of four parameters means 625 combinations, and each one multiplies by your number of CV folds.

Grids also trap you inside your own guesses. The best value might sit between two grid points or outside the grid entirely. And grids waste effort, because most hyperparameters matter far less than one or two key ones.

Here are the main weaknesses:

  1. Cost grows exponentially with every parameter you add.
  2. Coverage stays thin along the parameters that matter most.
  3. Edge results mislead you, since the true optimum may lie beyond the grid.
  4. Model comparison feels clumsy, because a single grid fits one model type.

Let us build a pipeline and watch these problems appear.

Set Up the Pipeline

We will use a synthetic classification dataset with 30 features, including some redundant ones. The pipeline scales the data, reduces its dimensions with PCA, and then fits a logistic regression. Every tuning method below targets the same pipeline.

import numpy as np
from scipy.stats import loguniform, randint
from sklearn.datasets import make_classification
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import (
    GridSearchCV, RandomizedSearchCV, StratifiedKFold, train_test_split
)

X, y = make_classification(
    n_samples=3000, n_features=30, n_informative=8,
    n_redundant=6, class_sep=0.8, flip_y=0.03, random_state=0
)
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42
)
cv = StratifiedKFold(5, shuffle=True, random_state=0)

pipe = Pipeline([
    ('scale', StandardScaler()),
    ('reduce', PCA()),
    ('clf', LogisticRegression(max_iter=2000)),
])

The double-underscore naming connects your search to the pipeline. A parameter like clf__C means "the C argument of the step named clf." Every method below uses this convention.

Start With a Normal Grid

Here is the baseline, a small grid over the number of PCA components and the regularization strength.

grid = {
    'reduce__n_components': [5, 10, 20],
    'clf__C': [0.01, 0.1, 1, 10],
}

gs = GridSearchCV(pipe, grid, cv=cv, scoring='roc_auc', n_jobs=-1)
gs.fit(X_tr, y_tr)
print(gs.best_params_, gs.best_score_)

The grid chose C=10 and 5 components, with a cross-validated AUC of 0.923 and a test AUC of 0.932. Notice something suspicious: C=10 sits at the top edge of the grid. That edge position hints that better values may exist beyond it.

Baseline grid result Value
Best parameters C = 10, 5 components
Cross-validated AUC 0.923
Test AUC 0.932
Suspicious detail C at the grid boundary

Randomized search samples parameter combinations from distributions instead of fixed lists. You set a budget with n_iter, and the search spends it on random points. Research on hyperparameter search shows that random search often beats grids, because it tests more distinct values of the parameters that matter.

dist = {
    'reduce__n_components': randint(3, 31),
    'clf__C': loguniform(1e-3, 1e2),
}

rs = RandomizedSearchCV(
    pipe, dist, n_iter=12, cv=cv,
    scoring='roc_auc', n_jobs=-1, random_state=0,
)
rs.fit(X_tr, y_tr)
print(rs.best_params_, rs.best_score_)

With the same budget of 12 candidates, randomized search found C near 16.6 and 30 components, which means no dimension reduction at all. The cross-validated AUC rose to 0.9445, and the test AUC reached 0.942. The grid missed that region completely.

Two details deserve attention:

  1. Use loguniform for scale-like parameters. Values like C and learning rates span orders of magnitude, so log-spacing samples them fairly.
  2. Use randint for counts. Parameters like tree depth and component counts need integers.

Method 2: Search Across Whole Models and Preprocessing

Pipelines let you tune more than numbers. You can swap entire steps in and out of the search. Pass a list of dictionaries, and the search treats each dictionary as its own sub-space.

from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier

pipe2 = Pipeline([
    ('scale', StandardScaler()),
    ('clf', LogisticRegression(max_iter=2000)),
])

space = [
    {'scale': [StandardScaler(), 'passthrough'],
     'clf': [LogisticRegression(max_iter=2000)],
     'clf__C': loguniform(1e-3, 1e2)},
    {'scale': ['passthrough'],
     'clf': [RandomForestClassifier(random_state=0)],
     'clf__n_estimators': randint(100, 400),
     'clf__max_depth': [None, 5, 10, 20]},
    {'scale': ['passthrough'],
     'clf': [HistGradientBoostingClassifier(random_state=0)],
     'clf__learning_rate': loguniform(0.01, 0.3),
     'clf__max_leaf_nodes': randint(8, 64)},
]

rs2 = RandomizedSearchCV(
    pipe2, space, n_iter=20, cv=cv,
    scoring='roc_auc', n_jobs=-1, random_state=0,
).fit(X_tr, y_tr)

The value 'passthrough' skips a step entirely, which lets the search decide whether scaling helps at all. Tree models ignore scaling, so I disabled it for them.

On my run, gradient boosting won with a cross-validated AUC of 0.964 and a test AUC of 0.956. Random forest reached 0.961, and logistic regression topped out at 0.9445. One search compared three model families and a preprocessing choice, all at once.

Scikit-learn samples a sub-space first, then samples within it. With several sub-spaces, each one gets an equal chance, regardless of its size. Adjust n_iter or the list structure if you want a different balance.

Model in the space Cross-validated AUC Test AUC
Logistic regression 0.9445 0.942
Random forest 0.961 —
Hist gradient boosting 0.964 0.956

Method 3: Successive Halving

Successive halving treats tuning like a tournament. It starts many candidates on a small slice of the data, drops the weak ones, and gives the survivors more data in the next round. Bad configurations die cheaply, and good ones earn more resources.

from sklearn.experimental import enable_halving_search_cv  # noqa
from sklearn.model_selection import HalvingRandomSearchCV
from sklearn.ensemble import HistGradientBoostingClassifier

hgb = HistGradientBoostingClassifier(random_state=0)
dist = {
    'learning_rate': loguniform(0.01, 0.3),
    'max_leaf_nodes': randint(8, 64),
    'min_samples_leaf': randint(5, 80),
    'l2_regularization': loguniform(1e-4, 10),
}

halving = HalvingRandomSearchCV(
    hgb, dist, n_candidates=40, factor=3,
    resource='n_samples', min_resources=300,
    cv=cv, scoring='roc_auc', n_jobs=-1, random_state=0,
).fit(X_tr, y_tr)

The import line matters, because halving search still sits behind an experimental flag. In my run, halving started 40 candidates on 300 samples, then kept the best 14 for a second round on 900 samples.

Here is the payoff, measured on my machine:

Search Candidates Wall time Test AUC
Randomized search 40 ~51 s 0.9550
Successive halving 40 ~23 s 0.9552

Halving finished in less than half the time and landed on the same test score. It deserves more attention than it gets. One warning: halving's best_score_ comes from the final round's smaller data slice, so you cannot compare it directly with a full-data score. Judge both searches on held-out test data.

Method 4: Track Several Metrics at Once

One metric rarely tells the whole story. Scikit-learn lets you score several metrics in one search. You then pick which one decides the winner with the refit argument.

multi = RandomizedSearchCV(
    hgb, dist, n_iter=20, cv=cv,
    scoring={'auc': 'roc_auc', 'f1': 'f1'},
    refit='auc', n_jobs=-1, random_state=0,
).fit(X_tr, y_tr)

best = multi.best_index_
print(multi.cv_results_['mean_test_auc'][best])
print(multi.cv_results_['mean_test_f1'][best])

The AUC winner scored an F1 of 0.911, while the best F1 anywhere in the search reached 0.914. The two metrics agreed closely here. On other problems, they can disagree sharply, and the multi-metric table reveals the trade-off.

Method 5: Check for Optimism With Nested Cross-Validation

Here is a quiet problem. When you tune on cross-validation folds and then report the best cross-validation score, you report a slightly optimistic number. The search picked that score because it looked good. Nested cross-validation fixes this by running the whole search inside an outer loop.

from sklearn.model_selection import cross_val_score

inner = StratifiedKFold(3, shuffle=True, random_state=1)
outer = StratifiedKFold(5, shuffle=True, random_state=2)

search = RandomizedSearchCV(
    hgb, dist, n_iter=15, cv=inner,
    scoring='roc_auc', n_jobs=-1, random_state=0,
)
nested = cross_val_score(search, X_tr, y_tr, cv=outer, scoring='roc_auc')
print(nested.mean(), nested.std())

My nested estimate came out at 0.9667, with a standard deviation of 0.0107. The non-nested tuned score read 0.9664, which sits almost on top of it. So the optimism stayed tiny here, mostly because the dataset has 2,400 training rows and the search space was modest.

Expect a bigger gap on small datasets or huge search spaces. Nested CV costs a lot of compute, so use it as a final audit, not as a daily habit. The extra honesty pays off when you plan to quote a number in a report.

Estimate AUC
Non-nested tuned CV score 0.9664
Nested CV (mean ± SD) 0.9667 ± 0.0107
Held-out test 0.956

Read the Results Like a Detective

The cv_results_ attribute holds every candidate's scores. Load it into a DataFrame and look beyond the winner. You will often find that the top ten configurations score almost the same.

import pandas as pd

results = pd.DataFrame(rs.cv_results_).sort_values('rank_test_score')
print(results[['mean_test_score', 'std_test_score']].head())

In my 40-iteration run, the top five configurations scored between 0.9653 and 0.9664, a spread of about 0.001. That spread falls well inside the fold-to-fold noise. When many settings tie, pick the simplest or fastest one instead of obsessing over the winner.

Practical Tips for Better Searches

Keep these habits in your toolbox:

  1. Start wide, then narrow. Run a broad randomized search first, then zoom into the promising region.
  2. Watch the edges. If the best value sits at a boundary, extend the range.
  3. Set n_jobs=-1 to use every core, and set random_state for reproducible searches.
  4. Put preprocessing inside the pipeline. That keeps every fold honest and prevents leakage.
  5. Prefer a test set for the final verdict. Searches reuse the training data repeatedly, so only fresh data gives an unbiased score.
  6. Match the metric to the goal. Tune for AUC, F1, or cost, depending on what the project needs.

If you tune the same preprocessing repeatedly, consider caching it with the memory argument of Pipeline. That avoids refitting expensive early steps for every candidate.

Common Pitfalls

I have watched these mistakes waste days of compute:

  1. Trusting a grid result at the edge. The best value probably lies beyond it.
  2. Linear sampling for scale parameters. Use log scales for C, learning rates, and regularization.
  3. Preprocessing outside the pipeline. Fitting a scaler on all data leaks information into validation folds.
  4. Reporting the best CV score as the final result. It carries optimism. Use a test set or nested CV.
  5. Over-tuning tiny gains. Differences smaller than fold-to-fold noise mean nothing.
  6. Comparing best_score_ across searches with different cv objects or resources. The scores are computed on different splits, so the comparison is meaningless.
  7. Running 500 iterations and reading only the winner. The top-ten table is where the real information lives.

Best Book to Read

CoverBookDescriptionGet it
Cover of “Hyperparameter Tuning with Python” Hyperparameter Tuning with Pythonby Louis Owen covers search strategies beyond the grid, including randomized and Bayesian approaches, with the same emphasis on budgeting compute rather than guessing parameter lists. View on Amazon

As an Amazon Associate I earn from qualifying purchases. Links on this page are affiliate links, and I may earn a commission at no extra cost to you.

Wrapping Up

GridSearchCV works for small, simple problems. For everything else, randomized search, successive halving, model-swapping spaces, multi-metric scoring, and nested CV give you more coverage for less time. My own runs showed a grid missing a better region, and halving matching random search in half the time.

So take your current search, swap the grid for distributions, and set a fixed budget. Then check whether your winner sat at an edge. That one change usually finds a better model in less time.

If your evaluation needs the same rigour, the F1 averaging guide explains which score to put inside scoring, and the threshold guide covers the decision that comes after tuning. The classification recipes post shows the whole pipeline in context.

Frequently Asked Questions

Why is GridSearchCV often worse than RandomizedSearchCV?

Because a grid spends its budget on a lattice, not on the parameters that matter. With four parameters and five values each you get 625 fits, yet each parameter is only tested at five distinct values. Random search samples from distributions, so with the same budget it probes far more distinct values of the important parameter. Bergstra and Bengio showed that random search finds equal or better configurations with far fewer trials.

What does the edge of the grid tell you?

It tells you the optimum is probably outside the range you searched. In the baseline run here, the grid picked C=10, the top of the list, which means the score was still improving when the grid ran out of values. Extend the boundary and rerun, or switch to a log-uniform distribution that covers orders of magnitude rather than three hand-picked points.

How does successive halving save time?

It allocates resources in rounds. Many candidates start on a small slice of data, the weakest are dropped by a factor each round, and survivors get more samples. Bad configurations fail on 300 rows instead of 2,400, so they cost a fraction of a full fit. In this run halving finished in about 23 seconds against 51 seconds for the same number of candidates, with the same test AUC.

Why can't I compare halving's best_score_ with a normal search score?

Because the final round may still be evaluated on a smaller data slice than the full training set, so its cross-validated score is computed under different conditions. The numbers are not on the same scale. Compare competing searches on a held-out test set or with nested cross-validation, where every candidate is judged on identical data.

What is nested cross-validation for?

It separates tuning from evaluation. An inner loop selects hyperparameters and an outer loop scores that whole selection procedure on data the inner loop never touched. The result estimates how well your pipeline-building process generalises, not how well one lucky configuration scored. Here the nested estimate was 0.9667 against a non-nested 0.9664, so the optimism was small, but it can be large on small datasets.

How many random search iterations should I run?

Enough to see the top configurations cluster. If the best five scores span 0.001, more iterations will not help; you are inside fold-to-fold noise and should pick the simplest candidate. A practical routine is a broad run of 40 to 100 candidates, inspect the top ten, then narrow the distributions around the winning region and run again.

Sources

  • [1] scikit-learn, RandomizedSearchCV API reference — n_iter, param_distributions, scoring as a dict, and refit by metric name: scikit-learn.org/.../RandomizedSearchCV
  • [2] scikit-learn, HalvingRandomSearchCV API reference — n_candidates, factor, resource, min_resources, and the experimental import flag: scikit-learn.org/.../HalvingRandomSearchCV
  • [3] scikit-learn, "Tuning the hyper-parameters of an estimator" — grid, random, halving and Bayesian search compared, with the memory caching and passthrough guidance: scikit-learn.org/modules/grid_search
  • [4] scikit-learn, "Cross-validation: nested cross-validation" — the inner/outer loop setup and why it gives a less biased estimate: scikit-learn.org/modules/cross_validation
  • [5] Bergstra and Bengio, "Random Search for Hyper-Parameter Optimization" (JMLR, 2012) — the empirical case for random search over grid search, including the low effective dimensionality argument: jmlr.org/papers/v13/bergstra12a

What are You Looking For?

esc