Random Search: Complete Guide — Hyperparameter Optimization in Python
Summary
Random Search is a hyperparameter optimization method based on random sampling of combinations from a predefined search space. Unlike Grid Search, which systematically explores every possible combination, Random Search randomly draws a fixed number of configurations and evaluates each one through cross-validation. This approach, introduced in the machine learning context by Bergstra and Bengio in 2012, proves remarkably effective — often superior to Grid Search — because most models depend strongly on only a few hyperparameters among all those being tuned.
In this complete guide, we will explore the mathematical principle of Random Search, its intuition, its practical implementation with scikit-learn, its advantages and limitations, as well as four concrete use cases.
Mathematical principle
Sampling in hyperparameter space
Random Search defines a search space (\mathcal{H} = \mathcal{H}_1 \times \mathcal{H}_2 \times \cdots \times \mathcal{H}_k) for (k) hyperparameters. At each iteration (i \in {1, \dots, n}), a configuration (\lambda^{(i)} = (\lambda_1^{(i)}, \dots, \lambda_k^{(i)})) is drawn according to a probability distribution defined on (\mathcal{H}).
The most common distributions are:
- Uniform distribution: (\lambda_j \sim \mathcal{U}(a_j, b_j)) for a bounded continuous hyperparameter. Each value in the interval ([a_j, b_j]) has the same probability of being selected.
- Log-uniform distribution: (\log(\lambda_j) \sim \mathcal{U}(\log(a_j), \log(b_j))). This distribution is particularly well-suited for hyperparameters whose impact is multiplicative (learning rate, regularization), as it samples evenly across each order of magnitude.
- Discrete uniform distribution: (\lambda_j \sim \text{RandInt}(a_j, b_j)) for an integer hyperparameter.
Probability of converging to the optimum
A fundamental theoretical result of Random Search concerns the probability of finding a solution (\epsilon)-close to the optimum. Let (\epsilon) be the fraction of the search space volume corresponding to configurations whose performance is in the top (\epsilon) percent. Then, after (n) iterations, the probability of having found at least one such configuration is:
$$
P(\text{success}) = 1 – (1 – \epsilon)^n
$$
For example, if (\epsilon = 0.05) (5% of the space contains very good configurations) and (n = 60) iterations, then (P = 1 – 0.95^{60} \approx 95.4)%. With only 60 random draws, there is over a 95% chance of landing on an excellent configuration.
The Bergstra and Bengio theorem (2012)
The seminal paper by Bergstra and Bengio, “Random Search for Hyper-Parameter Optimization” (Journal of Machine Learning Research, 2012), demonstrates that Random Search is more efficient than Grid Search when not all hyperparameters have the same impact on model performance — which is the case for the vast majority of practical problems.
The key intuition is as follows: in a high-dimensional search space, Grid Search wastes enormous amounts of evaluations by unnecessarily exploring hyperparameters that have little importance, while Random Search exhaustively explores each hyperparameter individually. If only (d) out of (D) hyperparameters are truly influential, Grid Search spends its budget on (N^D) combinations, but Random Search explores (n) different values for each of the (D) hyperparameters, offering far superior coverage along the important axes.
Intuition: why randomness beats the grid
Imagine you are looking for a hidden treasure somewhere on a vast territory. You know it is located at coordinates ((x, y)) with (x \in [0, 100]) and (y \in [0, 100]).
Grid Search is like methodically gridding a small corner of the territory: you place a 10 × 10 grid and dig at each intersection. The problem? This small corner covers only 10% of the territory. If the treasure is elsewhere, you will never find it. Moreover, you spent 100 evaluations exploring a limited area.
Random Search is like throwing 60 darts randomly across the entire map. Each throw explores the full territory, even if the points are scattered. If the treasure lies in an area corresponding to 5% of the territory, you have (1 – 0.95^{60} \approx 95)% chance of having at least one dart in the right area.
This is exactly what happens with hyperparameters: a model’s performance depends mainly on a few key hyperparameters (like the learning rate or the maximum depth of a tree), while others have a marginal impact. Random Search explores all possible values of these important hyperparameters, while Grid Search spends a good portion of its budget varying inconsequential hyperparameters.
This analogy explains why, in practice, a Random Search with 50 iterations often beats a Grid Search with 243 combinations.
Complete Python implementation
Installing dependencies
# Scikit-learn includes RandomizedSearchCV natively
# scipy provides statistical distributions
# pip install scikit-learn scipy numpy
Example 1: RandomizedSearchCV on a Random Forest
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
from sklearn.model_selection import RandomizedSearchCV, train_test_split
from sklearn.metrics import classification_report
from scipy.stats import randint, uniform
import numpy as np
# Generate a synthetic dataset
X, y = make_classification(
n_samples=10000, n_features=20, n_informative=15,
n_redundant=5, n_classes=2, random_state=42
)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
# Define the base model
rf = RandomForestClassifier(random_state=42)
# Hyperparameter search space
param_dist = {
'n_estimators': randint(50, 500), # Number of trees: 50 to 549
'max_depth': randint(3, 50), # Max depth: 3 to 52
'min_samples_split': randint(2, 30), # Minimum samples to split
'min_samples_leaf': randint(1, 20), # Minimum samples per leaf
'max_features': uniform(0.1, 0.9), # Feature fraction: 0.1 to 1.0
'bootstrap': [True, False], # Sampling with/without replacement
'criterion': ['gini', 'entropy'] # Split criterion
}
# Random search with cross-validation
random_search = RandomizedSearchCV(
estimator=rf,
param_distributions=param_dist,
n_iter=100, # 100 configurations tested
cv=5, # 5-fold cross-validation
scoring='accuracy', # Evaluation metric
n_jobs=-1, # Use all CPU cores
random_state=42,
verbose=1,
return_train_score=True
)
random_search.fit(X_train, y_train)
# Results
print(f"Best score: {random_search.best_score_:.4f}")
print(f"Best hyperparameters: {random_search.best_params_}")
# Evaluation on test set
y_pred = random_search.predict(X_test)
print(classification_report(y_test, y_pred))
Example 2: Grid Search vs Random Search comparison
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
import time
# Parameter grid for Grid Search (more restrictive)
param_grid = {
'n_estimators': [100, 200, 300, 400],
'max_depth': [10, 20, 30, None],
'min_samples_split': [2, 5, 10],
'min_samples_leaf': [1, 2, 4],
'criterion': ['gini', 'entropy']
}
# Grid Search: 4 × 4 × 3 × 3 × 2 = 288 combinations
# Direct comparison
t0 = time.time()
grid_search = GridSearchCV(
estimator=rf, param_grid=param_grid,
cv=3, scoring='accuracy', n_jobs=-1
)
grid_search.fit(X_train, y_train)
t_grid = time.time() - t0
t0 = time.time()
rand_search = RandomizedSearchCV(
estimator=rf, param_distributions=param_dist,
n_iter=50, cv=3, scoring='accuracy', n_jobs=-1, random_state=42
)
rand_search.fit(X_train, y_train)
t_rand = time.time() - t0
print(f"Grid Search — Score: {grid_search.best_score_:.4f}, Time: {t_grid:.1f}s, Combinations: {grid_search.n_iter_}")
print(f"Random Search — Score: {rand_search.best_score_:.4f}, Time: {t_rand:.1f}s, Combinations: {rand_search.n_iter_}")
In many practical cases, Random Search achieves a comparable (or even superior) score to Grid Search in a fraction of the time, because it explores a much larger search space with the same computational budget.
Example 3: Advanced scipy distributions for Deep Learning
from scipy.stats import loguniform
from sklearn.neural_network import MLPClassifier
# Log-uniform distribution for learning rate
# Samples evenly across each order of magnitude
# loguniform(rvs) draws in [1e-5, 1] in a log-uniform way
param_dist_nn = {
'hidden_layer_sizes': [
(50,), (100,), (50, 25), (100, 50), (200, 100, 50)
],
'alpha': loguniform(1e-5, 1e-1), # L2 regularization
'learning_rate_init': loguniform(1e-4, 1e-1), # Learning rate
'batch_size': randint(32, 256), # Batch size
'activation': ['relu', 'tanh', 'logistic'],
'solver': ['adam', 'sgd'],
'max_iter': [300, 500, 1000]
}
mlp = MLPClassifier(random_state=42)
random_search_nn = RandomizedSearchCV(
estimator=mlp,
param_distributions=param_dist_nn,
n_iter=80,
cv=3,
scoring='accuracy',
n_jobs=-1,
random_state=42
)
random_search_nn.fit(X_train, y_train)
print(f"Best MLP params: {random_search_nn.best_params_}")
The log-uniform distribution is essential here: the optimal learning rate typically lies between (10^{-5}) and (10^{-1}), and the log-uniform ensures we explore the orders (10^{-5}), (10^{-4}), (10^{-3}), (10^{-2}), and (10^{-1}) equally, rather than concentrating only on the larger values as a classical uniform distribution would.
Key parameters of RandomizedSearchCV
| Parameter | Type | Description |
|---|---|---|
param_distributions |
dict | Dictionary mapping each hyperparameter name to a distribution (scipy.stats) or a list of values |
n_iter |
int | Number of random configurations to evaluate. This is the main budget: the higher it is, the better the chances of finding the optimum |
cv |
int or splitter | Cross-validation strategy. Default is 5. Can be an integer (number of folds), a CV splitter object, or an iterable of train/test splits |
scoring |
str or callable | Optimization metric: ‘accuracy’, ‘f1’, ‘roc_auc’, ‘neg_mean_squared_error’, etc. For regression, ‘neg_mean_squared_error’ is commonly used |
n_jobs |
int | Number of parallel jobs. -1 uses all available cores |
random_state |
int | Random seed for reproducibility of draws |
refit |
bool or str | If True (default), the best model is refit on the entire training set after the search |
verbose |
int | Verbosity level. 1 displays one line per iteration |
return_train_score |
bool | If True, includes training scores in cv_results_ |
error_score |
float or ‘raise’ | Value assigned in case of a fitting error. Default is np.nan |
Choosing the number of iterations (n_iter)
The choice of n_iter is a trade-off between solution quality and computational cost:
- 10-20 iterations: quick exploration, useful for a first overview
- 50-100 iterations: good compromise, recommended for most problems
- 200-500 iterations: thorough search, for competitions or critical projects
- 1000+ iterations: overkill for most cases, consider more sophisticated methods instead (Bayesian Optimization)
Bergstra’s empirical rule suggests that n_iter = 60 is often sufficient to outperform an exhaustive Grid Search in a space of 5-7 hyperparameters.
Advantages and limitations
Advantages
- Superior computational efficiency: For the same budget of (n) evaluations, Random Search explores a much larger search space than Grid Search. In a 6-hyperparameter space with 5 values each, Grid Search requires (5^6 = 15\,625) evaluations, while Random Search will test (n = 100) configurations drawn from continuous distributions — each hyperparameter will potentially have 100 distinct values, compared to only 5 for Grid Search.
- Support for continuous distributions: Unlike Grid Search, which is limited to discrete grids, Random Search allows sampling from realistic distributions (log-uniform for learning rates, exponential for regularization), which better reflects the nature of hyperparameters.
- Independence of hyperparameters at launch: Generating all configurations in advance allows trivial parallelization: each configuration can be evaluated independently, on different machines, without intermediate communication.
- Implementation simplicity: No complex logic is required. The algorithm does not require a surrogate model, acquisition function, or iterative updating of a probability distribution as a Bayesian optimizer would.
-
Perfect reproducibility: With a fixed random seed (
random_state), results are strictly reproducible, which is crucial for scientific validation and debugging.
Limitations
- No exploitation of acquired information: Random Search learns nothing from previous evaluations. Each configuration is drawn independently, without regard to observed performance. A Bayesian optimizer (Bayesian Optimization), by contrast, builds a probabilistic model of the objective function and guides future draws toward promising regions.
- No guaranteed convergence to the global optimum: The probability of finding the exact optimum is zero for continuous spaces. We only guarantee an (\epsilon)-close solution with a certain probability.
-
Fixed budget a priori: The number of iterations
n_itermust be set before launch. We cannot dynamically adjust the budget based on observed convergence. - May miss hyperparameter interactions: Uniform sampling assumes independence between hyperparameters. If optimal performance requires a specific combination of two correlated hyperparameters, Random Search may take a long time to discover it by pure chance.
4 practical use cases
Case 1: Tuning a classification model on imbalanced data
from sklearn.datasets import make_classification
from sklearn.model_selection import RandomizedSearchCV
from sklearn.ensemble import GradientBoostingClassifier
from scipy.stats import randint, loguniform
# Imbalanced dataset
X, y = make_classification(
n_samples=5000, n_features=30,
weights=[0.9, 0.1], random_state=42 # 90/10
)
param_dist = {
'n_estimators': randint(100, 500),
'learning_rate': loguniform(0.001, 0.3),
'max_depth': randint(3, 10),
'subsample': uniform(0.6, 0.4),
'min_samples_leaf': randint(5, 50)
}
search = RandomizedSearchCV(
GradientBoostingClassifier(random_state=42),
param_dist, n_iter=60, cv=3,
scoring='f1_macro', random_state=42, n_jobs=-1
)
search.fit(X, y)
print(f"Best F1 macro: {search.best_score_:.4f}")
The use of f1_macro scoring is crucial here: on an imbalanced dataset, accuracy would be a trap (a model that always predicts the majority class would achieve 90% accuracy). F1 macro penalizes each class equally.
Case 2: Optimizing a complete pipeline with preprocessing
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.linear_model import Ridge
pipe = Pipeline([
('poly', PolynomialFeatures()),
('scaler', StandardScaler()),
('ridge', Ridge())
])
param_dist_pipe = {
'poly__degree': randint(1, 5),
'poly__interaction_only': [True, False],
'ridge__alpha': loguniform(1e-3, 1e3),
'ridge__solver': ['auto', 'svd', 'cholesky', 'lsqr', 'saga']
}
search_pipe = RandomizedSearchCV(
pipe, param_dist_pipe,
n_iter=40, cv=5,
scoring='neg_mean_squared_error',
random_state=42
)
search_pipe.fit(X_train, y_train)
Random Search works perfectly with scikit-learn pipelines, allowing simultaneous tuning of preprocessing steps and the final model.
Case 3: Comparative model selection
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from scipy.stats import randint, loguniform, uniform
# We can compare multiple algorithms with the same Random Search
models = {
'rf': (RandomForestClassifier(random_state=42), {
'n_estimators': randint(50, 500),
'max_depth': randint(3, 30),
'max_features': uniform(0.1, 0.9)
}),
'gb': (GradientBoostingClassifier(random_state=42), {
'n_estimators': randint(50, 300),
'learning_rate': loguniform(0.001, 0.3),
'max_depth': randint(2, 8)
}),
'svm': (SVC(), {
'C': loguniform(0.01, 100),
'gamma': loguniform(0.001, 0.1),
'kernel': ['rbf', 'poly', 'sigmoid']
})
}
results = {}
for name, (model, params) in models.items():
search = RandomizedSearchCV(
model, params, n_iter=30, cv=5,
scoring='accuracy', random_state=42, n_jobs=-1
)
search.fit(X_train, y_train)
results[name] = search.best_score_
print(f"{name}: {search.best_score_:.4f} -> {search.best_params_}")
Case 4: Two-pass search (coarse-to-fine)
A common strategy is to run Random Search in two phases:
# Pass 1: broad search to identify orders of magnitude
param_dist_coarse = {
'n_estimators': randint(10, 1000),
'max_depth': randint(2, 100),
'learning_rate': loguniform(1e-5, 1.0),
'min_samples_split': randint(2, 100)
}
search_coarse = RandomizedSearchCV(
GradientBoostingClassifier(random_state=42),
param_dist_coarse, n_iter=100, cv=3,
scoring='roc_auc', random_state=42, n_jobs=-1
)
search_coarse.fit(X_train, y_train)
coarse_best = search_coarse.best_params_
# Pass 2: refinement around the best values found
learning_rate_center = coarse_best['learning_rate']
param_dist_fine = {
'n_estimators': randint(max(10, coarse_best['n_estimators']-100),
coarse_best['n_estimators']+100),
'max_depth': randint(max(2, coarse_best['max_depth']-10),
coarse_best['max_depth']+10),
'learning_rate': loguniform(learning_rate_center/10, learning_rate_center*10),
'min_samples_split': randint(max(2, coarse_best['min_samples_split']-10),
coarse_best['min_samples_split']+10)
}
search_fine = RandomizedSearchCV(
GradientBoostingClassifier(random_state=42),
param_dist_fine, n_iter=80, cv=5,
scoring='roc_auc', random_state=42, n_jobs=-1
)
search_fine.fit(X_train, y_train)
print(f"Refined score: {search_fine.best_score_:.4f}")
This approach combines the global coverage of the first pass with the local precision of the second, offering an excellent trade-off between exploration and exploitation.
See also
- Find the Smallest Multiple in Python: Complete Guide and Optimization Tips
- Implement the SHA-1 Hash Algorithm in Python: Step-by-Step Guide

