DEV Community

Cover image for Grid Search vs Random Search: The Safe With Three Dials Where Only One Was Connected
Sachin Kr. Rajput
Sachin Kr. Rajput

Posted on

Grid Search vs Random Search: The Safe With Three Dials Where Only One Was Connected

The One-Line Summary: Random search is supposed to beat grid search because grid wastes budget on parameters that don't matter, and the mechanism is exactly as advertised — but measured at a 64-trial budget the whole effect was 0.0039 in random's favour on six parameters and 0.0008 against it on three, while random search's spread against its own seed was 0.0098, so grid beat precisely 5 of 10 random seeds and the famous comparison came out a coin flip.


The Parable of the Safe at Voutier & Fils

The safe in the back office has three dials, each numbered 0 to 99, and the combination has been lost since the war. The firm hires a locksmith who explains that there are a million combinations and he charges by the hour, so the question is not whether he can open it but in what order he tries.

What nobody in the room knows yet is that only one of the three dials is connected to anything.


The Methodical Method

The locksmith proposes a grid. He will pick four settings on each dial — 0, 25, 50, 75 — and work through every combination systematically, so that nothing is ever tried twice and he can report exactly what has been eliminated.

THE METHODICAL METHOD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Four settings per dial, three dials:

  4 x 4 x 4  =  64 attempts

  dial A tried at:  0, 25, 50, 75
  dial B tried at:  0, 25, 50, 75
  dial C tried at:  0, 25, 50, 75

64 attempts. Four distinct positions of dial A.

He is thorough, he is auditable, and every one of
those 64 attempts moved dial B and dial C, which
are attached to nothing at all.

This is called GRID SEARCH.
Enter fullscreen mode Exit fullscreen mode

Sixty-four hours of work bought four guesses at the only dial that mattered.


The Careless Method

His apprentice, who is not methodical, spins all three dials at random and tries whatever comes up. Sixty-four times.

"You keep testing the same four numbers on the dial that opens it. I test sixty-four."

THE CARELESS METHOD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Sixty-four random spins of all three dials:

  dial A tried at:  7, 41, 88, 3, 62, 19 ... 64 values
  dial B tried at:  ... 64 values, all wasted
  dial C tried at:  ... 64 values, all wasted

Same 64 attempts. Same wasted dials. But SIXTEEN
TIMES the resolution on the dial that matters,
because he never spends two attempts on the same
setting of it.

He does not know which dial is connected either.
He does not need to.

This is called RANDOM SEARCH.
Enter fullscreen mode Exit fullscreen mode

The apprentice's advantage is not cleverness. It is that he refuses to repeat himself on any axis, so he cannot waste resolution on the axis that turns out to matter.


The Part The Story Usually Leaves Out

This is where the parable is normally allowed to end, and it should not be, because the firm ran both methods and wrote the results down.

WHAT ACTUALLY HAPPENED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
When all three dials were connected:
  ✓ the methodical locksmith won

When five of six dials were decoration:
  ✓ the apprentice won

Then the apprentice repeated his 64 spins on ten
different days. His results varied more between
days than the gap between the two methods.

✗ The firm had spent a decade arguing about a
  difference smaller than the apprentice's luck.
Enter fullscreen mode Exit fullscreen mode

The apprentice's argument is correct. At sixty-four attempts it is also almost entirely theoretical.


Why It Works

THE MATHEMATICS OF NOT REPEATING YOURSELF
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GRID over d parameters with n total trials:

    values per parameter = n^(1/d)

  d=2, n=64  ->  8 values each
  d=3, n=64  ->  4 values each
  d=6, n=64  ->  2 values each
  d=8, n=64  ->  fewer than 2; the grid cannot
                 even be built

  → resolution COLLAPSES as you add parameters,
    including the ones that do nothing

RANDOM over d parameters with n trials:

    values per parameter = n,  for every d

  → resolution is INDEPENDENT of dimension

  P(at least one draw in the top q fraction)
      = 1 - (1-q)^n

  This is the whole theoretical case, and it says
  nothing about how much BETTER the top q region
  is — which is why the effect size has to be
  measured rather than assumed.
Enter fullscreen mode Exit fullscreen mode

What Are Grid and Random Search?

Grid search enumerates the Cartesian product of a fixed set of values per parameter. It is exhaustive over that grid, reproducible without a seed, trivially parallel, and it tells you precisely which region you have ruled out.

Random search draws each trial independently from a distribution per parameter. It is not exhaustive over anything, and its guarantee is probabilistic: with nn draws, the chance of landing at least once inside the best qq fraction of the space is 1(1q)n1-(1-q)^n , independent of how many parameters there are.

That independence is the entire argument, and it comes from Bergstra and Bengio [1]. Their claim is not that random sampling is smarter. It is that most hyperparameter spaces have low effective dimensionality — a couple of parameters do nearly all the work — and grid search cannot know which, so it spends its resolution uniformly and therefore wrongly.

The Math

For grid search over dd parameters with nn total trials, the values tested per parameter is:

k=n1/d k = n^{1/d}

At n=64n=64 : three parameters gives k=4k=4 , six gives k=2k=2 , and eight gives k<2k<2 — the grid cannot be constructed without dropping a parameter.

For random search, k=nk=n regardless of dd , and the coverage probability is:

P(hit top q)=1(1q)n P(\text{hit top } q) = 1 - (1-q)^n

Note what this does not say. It bounds the probability of entering the top qq region; it says nothing about how much better that region is than the rest. If your loss surface is flat near the optimum, landing in the top 5% is worth almost nothing — and that gap between "found a good region" and "the good region is meaningfully better" is where the measured effect sizes below come from.

Step by Step

Grid: choose values per parameter, take the product, evaluate all, keep the best.

Random: choose a distribution per parameter — log-uniform for learning rates and regularisation, uniform for fractions, integer-uniform for depths — draw nn times, evaluate, keep the best.

The distribution choice is the part people skip, and it matters more than the grid-versus-random question. A learning rate sampled uniformly on [0.001, 0.3] puts roughly 90% of its draws above 0.03, which is not what you meant.


Equal Budget, Both Regimes

Sixty-four trials each, three-fold CV, gradient boosting on Friedman #1. The first regime gives both methods three parameters that genuinely matter. The second adds three that barely do — which is what forces grid's resolution down to two values per axis.

import warnings, numpy as np, itertools; warnings.filterwarnings("ignore")
from sklearn.datasets import make_friedman1
from sklearn.model_selection import train_test_split, KFold, cross_val_score
from sklearn.ensemble import GradientBoostingRegressor

X, y = make_friedman1(n_samples=800, noise=1.0, random_state=42)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=42)
cv = KFold(3, shuffle=True, random_state=0)

def score(p):
    return cross_val_score(GradientBoostingRegressor(n_estimators=60, random_state=0, **p),
                           Xtr, ytr, cv=cv, scoring='r2', n_jobs=-1).mean()

def run_grid(axes):
    keys = list(axes); best = -9; n = 0
    for vals in itertools.product(*axes.values()):
        n += 1; v = score(dict(zip(keys, vals)))
        if v > best: best = v
    return best, n, {k: len(v) for k, v in axes.items()}

def draw(spec, rng):
    p = {}
    for k, (kind, a, b) in spec.items():
        p[k] = float(np.exp(rng.uniform(np.log(a), np.log(b)))) if kind == 'logu' else \
               (int(rng.randint(a, b + 1)) if kind == 'int' else float(rng.uniform(a, b)))
    return p

def run_random(spec, budget, seed=0):
    rng = np.random.RandomState(seed); best = -9; seen = {k: set() for k in spec}
    for _ in range(budget):
        p = draw(spec, rng)
        for k in p: seen[k].add(round(p[k], 6))
        v = score(p)
        if v > best: best = v
    return best, {k: len(s) for k, s in seen.items()}

S3 = {"learning_rate":('logu',0.02,0.25), "max_depth":('int',2,5),
      "subsample":('unif',0.6,1.0)}
S6 = dict(S3, max_features=('unif',0.5,1.0), min_samples_leaf=('int',1,8),
          min_samples_split=('int',2,16))

print("REGIME A: 3 parameters, 64-trial budget (grid gets 4 values each)")
print("-" * 66)
gA = run_grid({"learning_rate":[0.02,0.05,0.11,0.25], "max_depth":[2,3,4,5],
               "subsample":[0.6,0.75,0.9,1.0]})
rA = run_random(S3, 64)
print(f"  grid   {gA[1]:>3} trials -> best CV {gA[0]:.4f}   lr values: {gA[2]['learning_rate']}")
print(f"  random  64 trials -> best CV {rA[0]:.4f}   lr values: {rA[1]['learning_rate']}")
print(f"  random - grid = {rA[0]-gA[0]:+.4f}")

print()
print("REGIME B: 6 parameters, same 64-trial budget (grid gets 2 values each)")
print("-" * 66)
gB = run_grid({"learning_rate":[0.05,0.25], "max_depth":[2,5], "subsample":[0.6,1.0],
               "max_features":[0.5,1.0], "min_samples_leaf":[1,8],
               "min_samples_split":[2,16]})
rB = run_random(S6, 64)
print(f"  grid   {gB[1]:>3} trials -> best CV {gB[0]:.4f}   lr values: {gB[2]['learning_rate']}")
print(f"  random  64 trials -> best CV {rB[0]:.4f}   lr values: {rB[1]['learning_rate']}")
print(f"  random - grid = {rB[0]-gB[0]:+.4f}")
Enter fullscreen mode Exit fullscreen mode
REGIME A: 3 parameters, 64-trial budget (grid gets 4 values each)
------------------------------------------------------------------
  grid    64 trials -> best CV 0.8729   lr values: 4
  random  64 trials -> best CV 0.8721   lr values: 64
  random - grid = -0.0008

REGIME B: 6 parameters, same 64-trial budget (grid gets 2 values each)
------------------------------------------------------------------
  grid    64 trials -> best CV 0.8688   lr values: 2
  random  64 trials -> best CV 0.8728   lr values: 64
  random - grid = +0.0039
Enter fullscreen mode Exit fullscreen mode

The crossover is real and it is in the predicted direction. With three parameters and four grid values each, grid won by 0.0008. With six parameters and two grid values each, random won by 0.0039.

Notice the resolution column, because that is the mechanism working exactly as described: random search tried 64 distinct learning rates in both regimes, while grid tried 4 and then 2. Adding three parameters that barely matter cut grid's resolution on the parameter that matters most in half, and cost it 0.0041 of CV score (0.8729 down to 0.8688) while random search lost almost nothing (0.8721 to 0.8728).

So Bergstra and Bengio are vindicated on mechanism. Now the awkward question: is either gap bigger than noise?


Is Any of This Bigger Than Luck?

Random search has a seed. Grid search does not. So run random search ten times and look at the spread.

import warnings, numpy as np; warnings.filterwarnings("ignore")
from sklearn.datasets import make_friedman1
from sklearn.model_selection import train_test_split, KFold, cross_val_score
from sklearn.ensemble import GradientBoostingRegressor

X, y = make_friedman1(n_samples=800, noise=1.0, random_state=42)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=42)
cv = KFold(3, shuffle=True, random_state=0)

def score(p):
    return cross_val_score(GradientBoostingRegressor(n_estimators=60, random_state=0, **p),
                           Xtr, ytr, cv=cv, scoring='r2', n_jobs=-1).mean()

S3 = {"learning_rate":('logu',0.02,0.25), "max_depth":('int',2,5),
      "subsample":('unif',0.6,1.0)}

def rand_best(spec, budget, seed):
    rng = np.random.RandomState(seed); best = -9
    for _ in range(budget):
        p = {}
        for k, (kind, a, b) in spec.items():
            p[k] = float(np.exp(rng.uniform(np.log(a), np.log(b)))) if kind == 'logu' else \
                   (int(rng.randint(a, b+1)) if kind == 'int' else float(rng.uniform(a, b)))
        v = score(p)
        if v > best: best = v
    return best

GRID_3P = 0.8729   # deterministic, from the previous experiment

print("RANDOM SEARCH, 64 TRIALS, TEN SEEDS  (3 params)")
print("-" * 60)
vals = [rand_best(S3, 64, s) for s in range(10)]
for s, v in enumerate(vals):
    print(f"  seed {s}  best CV {v:.4f}")
print()
print(f"  mean {np.mean(vals):.4f}   min {min(vals):.4f}   max {max(vals):.4f}"
      f"   spread {max(vals)-min(vals):.4f}   sd {np.std(vals):.4f}")
print(f"  grid (3 params) = {GRID_3P:.4f}; "
      f"grid beats {sum(1 for v in vals if v < GRID_3P)} of 10 seeds")

print()
print("COVERAGE ARITHMETIC (why random search is supposed to work)")
print("-" * 60)
for q in (0.10, 0.05, 0.01):
    for n in (30, 64, 200):
        print(f"  P(at least one of {n:>3} draws lands in the top {q:.0%}) = {1-(1-q)**n:.4f}")
Enter fullscreen mode Exit fullscreen mode
RANDOM SEARCH, 64 TRIALS, TEN SEEDS  (3 params)
------------------------------------------------------------
  seed 0  best CV 0.8721
  seed 1  best CV 0.8726
  seed 2  best CV 0.8738
  seed 3  best CV 0.8792
  seed 4  best CV 0.8723
  seed 5  best CV 0.8743
  seed 6  best CV 0.8694
  seed 7  best CV 0.8711
  seed 8  best CV 0.8765
  seed 9  best CV 0.8785

  mean 0.8740   min 0.8694   max 0.8792   spread 0.0098   sd 0.0030
  grid (3 params) = 0.8729; grid beats 5 of 10 seeds

COVERAGE ARITHMETIC (why random search is supposed to work)
------------------------------------------------------------
  P(at least one of  30 draws lands in the top 10%) = 0.9576
  P(at least one of  64 draws lands in the top 10%) = 0.9988
  P(at least one of 200 draws lands in the top 10%) = 1.0000
  P(at least one of  30 draws lands in the top 5%) = 0.7854
  P(at least one of  64 draws lands in the top 5%) = 0.9625
  P(at least one of 200 draws lands in the top 5%) = 1.0000
  P(at least one of  30 draws lands in the top 1%) = 0.2603
  P(at least one of  64 draws lands in the top 1%) = 0.4744
  P(at least one of 200 draws lands in the top 1%) = 0.8660
Enter fullscreen mode Exit fullscreen mode

Random search's spread against itself is 0.0098. The gap it won by in six dimensions is 0.0039. The gap it lost by in three is 0.0008. The noise is two and a half times the larger effect and twelve times the smaller one.

And the head-to-head is the cleanest way to say it: grid beat 5 of 10 random seeds. Exactly five. If I had run one seed and published, this article was a fair coin flip between "the famous result replicates" and "the famous result doesn't," from identical code on identical data at an identical budget.

The coverage table shows why the theory is nonetheless right, and where it stops helping. Sixty-four draws reach the top 10% essentially always (0.9988) and the top 5% almost always (0.9625). But the top 1% is worse than a coin flip at 0.4744, and getting there reliably takes about 200 draws (0.8660). Early in tuning, when you are still finding the right order of magnitude, 64 random draws are plenty. Late in tuning, when the remaining gains live in the top 1% of the space, 64 draws are not a search — they are a sample, and so is a grid.


The Searches from Scratch

Both searches above are hand-rolled — a nested loop over a Cartesian product, and a loop of independent draws. Neither uses GridSearchCV or RandomizedSearchCV, which is deliberate: the whole argument is about how a budget gets spent across axes, and that is easier to see when the loop is visible. Worth confirming the from-scratch grid agrees with the library, though, since every number above rests on it.

import warnings, numpy as np, itertools; warnings.filterwarnings("ignore")
from sklearn.datasets import make_friedman1
from sklearn.model_selection import (train_test_split, KFold, cross_val_score,
                                     GridSearchCV)
from sklearn.ensemble import GradientBoostingRegressor

X, y = make_friedman1(n_samples=800, noise=1.0, random_state=42)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=42)
cv = KFold(3, shuffle=True, random_state=0)
AXES = {"learning_rate":[0.02,0.05,0.11,0.25], "max_depth":[2,3,4,5],
        "subsample":[0.6,0.75,0.9,1.0]}

def score(p):
    return cross_val_score(GradientBoostingRegressor(n_estimators=60, random_state=0, **p),
                           Xtr, ytr, cv=cv, scoring='r2', n_jobs=-1).mean()

mine = max(score(dict(zip(AXES, v))) for v in itertools.product(*AXES.values()))
skl = GridSearchCV(GradientBoostingRegressor(n_estimators=60, random_state=0),
                   AXES, cv=cv, scoring='r2', n_jobs=-1).fit(Xtr, ytr)
print("GRID SEARCH FROM SCRATCH vs SCIKIT-LEARN   (best CV r2)")
print("-" * 56)
print(f"  mine (nested loop over the product) {mine:.4f}")
print(f"  sklearn GridSearchCV                {skl.best_score_:.4f}")
print(f"  trials: mine {4*4*4}, sklearn {len(skl.cv_results_['params'])}")
Enter fullscreen mode Exit fullscreen mode
GRID SEARCH FROM SCRATCH vs SCIKIT-LEARN   (best CV r2)
--------------------------------------------------------
  mine (nested loop over the product) 0.8729
  sklearn GridSearchCV                0.8729
  trials: mine 64, sklearn 64
Enter fullscreen mode Exit fullscreen mode

Identical, as it should be — GridSearchCV is a nested loop with a progress bar and better parallelism. Which is worth saying plainly: there is nothing in either library search that you could not write in six lines, and neither of them does anything with the results of trial 1 before choosing trial 2.


When Does Each Help Most?

Grid search Random search
Resolution per parameter n1/dn^{1/d} , collapses with dd nn , independent of dd
Reproducible exactly, no seed only with the seed
States what you ruled out yes no
Handles 6+ parameters badly fine
Resume with more budget no, the grid is fixed yes, draw more
Measured, 3 params 0.8729 0.8740 mean, 0.0098 spread
Measured, 6 params 0.8688 0.8728

Reach for grid when you have two or three parameters, when you want an auditable statement about what was eliminated, or when the parameters are genuinely categorical with few levels.

Reach for random when you have four or more parameters, when you want to add budget later without restarting, or when you do not know which parameters matter — which is most of the time.

Reach for neither when the honest answer is that 64 trials cannot resolve your question. Both methods here are sampling blind; neither uses what the previous 63 trials revealed. That is tomorrow's article.


Quick Reference Card

GRID vs RANDOM SEARCH: CHEAT SHEET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RESOLUTION PER PARAMETER at n = 64 trials
  grid, 2 params    8 values      random   64
  grid, 3 params    4 values      random   64
  grid, 6 params    2 values      random   64
  grid, 8 params    cannot build  random   64

COVERAGE, random search
  top 10%   30 draws 0.9576   64 draws 0.9988
  top  5%   30 draws 0.7854   64 draws 0.9625
  top  1%   30 draws 0.2603   64 draws 0.4744
            -> the top 1% needs ~200 draws (0.8660)

MEASURED (64 trials, GBM on Friedman #1)
  3 params   grid 0.8729   random 0.8740 mean
  6 params   grid 0.8688   random 0.8728
  random seed spread: 0.0098  <- WIDER than
             either gap (0.0008 and 0.0039)
  grid beat 5 of 10 random seeds

RULES
   log-uniform for learning rates, regularisation
   4+ parameters: use random
   2-3 parameters: grid is fine and auditable
   report the seed spread, never one seed
   64 trials cannot resolve the top 1%

SKLEARN
  from sklearn.model_selection import GridSearchCV
  from sklearn.model_selection import RandomizedSearchCV
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. The mechanism is real and visible — random search tried 64 distinct learning rates at every dimension; grid tried 4 at three parameters and 2 at six.

  2. Adding useless parameters costs grid real score — 0.8729 down to 0.8688 when three near-irrelevant parameters halved its resolution, while random went 0.8721 to 0.8728.

  3. The crossover happened where predicted — grid ahead by 0.0008 on three parameters, random ahead by 0.0039 on six.

  4. Both gaps are inside the noise — random search's seed-to-seed spread was 0.0098, with a standard deviation of 0.0030.

  5. Grid beat exactly 5 of 10 random seeds, which is the most honest possible summary of the comparison at this budget.

  6. Coverage explains both the theory and its limit — 0.9988 for the top 10% at 64 draws, but only 0.4744 for the top 1%.

  7. Grid resolution collapses as n1/dn^{1/d} — at 64 trials and 8 parameters the grid cannot be built at all.

  8. The sampling distribution matters more than the method — uniform draws of a learning rate on [0.001, 0.3] land mostly above 0.03, and no grid-versus-random argument repairs that.


The One-Sentence Summary

Random search's advantage over grid search is a real mechanism that behaved exactly as advertised — it held 64 distinct learning rates at every dimension while grid collapsed from 4 values to 2 and lost 0.0041 of score doing it — and yet the head-to-head gaps were 0.0008 and 0.0039 against a seed-to-seed spread of 0.0098, so grid beat five of ten random seeds and the correct conclusion at a 64-trial budget is not that one method wins but that neither method is learning anything from the trials it already ran.


What's Next?

Now that you know why both methods are guessing, you're ready for:

  1. Bayesian optimization — tomorrow. The search that reads its own results before choosing the next trial.
  2. The 5-minute Optuna setup — replacing your grid search tonight.
  3. Nested cross-validation — how to tune and report without burning the same data twice.
  4. Early stopping inside a search — killing bad trials at iteration 10 instead of 200.

Follow me for the next article in the Hyperparameter Tuning series!


Let's Connect!

If the safe at Voutier & Fils made the resolution argument click, drop a heart!

Questions? Ask in the comments — I read and respond to every one.

Have you ever published a tuning comparison from one seed? I nearly did. The first version of this ran seed 0, found grid ahead, and I was three paragraphs into explaining why the famous result fails to replicate before it occurred to me to try seed 1. Seed 3 would have had me arguing the opposite just as confidently. 🔐


References

[1] J. Bergstra and Y. Bengio, Random Search for Hyper-Parameter Optimization (2012), Journal of Machine Learning Research 13


What makes this one worth measuring rather than citing is that the original paper is entirely correct and almost universally over-applied. Bergstra and Bengio proved something specific about resolution in high-dimensional spaces with low effective dimensionality, and the profession compressed it into "random search beats grid search" — a different claim, one that depends on a budget and a loss surface the paper never promised anything about. The paper is not wrong. The sentence it turned into is.


Before your next tuning run, count your parameters — then run the search twice with different seeds and check whether the gap you were about to report survives.

Top comments (0)