What to Tune, and How to Search
Which hyperparameters matter, and why random search beats a grid
Every model so far has been fitted with whatever settings scikit-learn ships. Sometimes that is close to optimal and sometimes it is not, and the difference between a productive week of tuning and a wasted one is almost entirely about which settings you touch.
Not all hyperparameters matter
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.model_selection import train_test_split
d = df.copy()
d['is_monthly'] = (d['contract'] == 'Month-To-Month').astype(int)
d['is_fibre'] = (d['internet_service'] == 'Fibre optic').astype(int)
FEATS = ['tenure_months', 'support_calls', 'monthly_charges',
'total_charges', 'is_monthly', 'is_fibre']
X_tr, X_te, y_tr, y_te = train_test_split(
d[FEATS], d['churned'], test_size=0.25, stratify=d['churned'],
random_state=42)
# Impute after the split, with medians learned from the training rows
# only -- week 4's rule still applies. A Pipeline is the right way to do
# this; these pages need a plain frame to inspect column by column, so
# the two lines are written out instead.
medians = X_tr.median()
X_tr = X_tr.fillna(medians)
X_te = X_te.fillna(medians)
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import cross_val_score
def score(**kw):
m = HistGradientBoostingClassifier(random_state=42, **kw)
return cross_val_score(m, X_tr, y_tr, cv=5, scoring='roc_auc').mean()
base = score()
print('defaults %.4f\n' % base)
print('%-22s %-18s %9s %9s' % ('parameter', 'values', 'best', 'spread'))
sweeps = {
'learning_rate': [0.01, 0.05, 0.1, 0.3],
'max_leaf_nodes': [7, 15, 31, 63],
'min_samples_leaf': [5, 20, 50, 100],
'l2_regularization': [0.0, 0.1, 1.0, 10.0],
'max_features': [0.4, 0.7, 1.0],
}
for name, values in sweeps.items():
got = [score(**{name: v}) for v in values]
print('%-22s %-18s %9.4f %9.4f'
% (name, str(values)[:18], max(got), max(got) - min(got)))
parameter values best spread
learning_rate [0.01, 0.05, 0.1, 0.8012 0.0207
max_leaf_nodes [7, 15, 31, 63] 0.8120 0.0238
min_samples_leaf [5, 20, 50, 100] 0.8069 0.0144
l2_regularization [0.0, 0.1, 1.0, 10 0.8032 0.0117
max_features [0.4, 0.7, 1.0] 0.7992 0.0063
Read the spread column. One or two parameters move the score by something worth having and the rest barely register, and that pattern, not the specific winner, is what generalises. It is why the next two pages are about search strategy rather than about parameter values.
| Model | Tune first | Tune if you have budget | Leave alone |
|---|---|---|---|
| Gradient boosting | learning_rate, max_leaf_nodes, n_estimators | min_samples_leaf, l2_regularization, subsampling | Almost everything else |
| Random forest | max_features, min_samples_leaf | max_depth | n_estimators: set it as high as you can afford |
| Linear / logistic | C or alpha | penalty type, class_weight | The solver, usually |
| SVM (RBF) | C, gamma | kernel choice | - |
| Neural network | learning rate | width, depth, dropout, batch size | The optimiser, use Adam |
n_estimators is not a hyperparameter in a forest
More trees in a random forest never makes it worse, it only makes it slower. The variance of the average falls and then flattens. So there is nothing to search: pick the largest number you can afford and move on. In boosting it is entirely different, because each tree corrects the last and too many will overfit. Same argument name, opposite behaviour.
What a grid actually costs
'max_leaf_nodes': [7, 15, 31, 63],
'min_samples_leaf': [5, 20, 50],
'l2_regularization': [0.0, 0.1, 1.0]}
total = 1
for name, values in params.items():
total *= len(values)
print('%-22s %d values, running total %d' % (name, len(values), total))
for folds in [5, 10]:
fits = total * folds
print('\n%d-fold: %d fits' % (folds, fits))
for secs in [0.2, 2.0]:
print(' at %.1fs per fit: %.1f minutes' % (secs, fits * secs / 60))
max_leaf_nodes 4 values, running total 16
min_samples_leaf 3 values, running total 48
l2_regularization 3 values, running total 144
5-fold: 720 fits
at 0.2s per fit: 2.4 minutes
at 2.0s per fit: 24.0 minutes
10-fold: 1440 fits
at 0.2s per fit: 4.8 minutes
at 2.0s per fit: 48.0 minutes
Adding one more value to one more parameter multiplies the cost. A grid is the only strategy whose price grows exponentially in the number of things you are curious about, which is a strange property for the default choice to have.
Random search, and why it wins
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.model_selection import train_test_split
d = df.copy()
d['is_monthly'] = (d['contract'] == 'Month-To-Month').astype(int)
d['is_fibre'] = (d['internet_service'] == 'Fibre optic').astype(int)
FEATS = ['tenure_months', 'support_calls', 'monthly_charges',
'total_charges', 'is_monthly', 'is_fibre']
X_tr, X_te, y_tr, y_te = train_test_split(
d[FEATS], d['churned'], test_size=0.25, stratify=d['churned'],
random_state=42)
# Impute after the split, with medians learned from the training rows
# only -- week 4's rule still applies. A Pipeline is the right way to do
# this; these pages need a plain frame to inspect column by column, so
# the two lines are written out instead.
medians = X_tr.median()
X_tr = X_tr.fillna(medians)
X_te = X_te.fillna(medians)
import time
from scipy.stats import loguniform, randint
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
grid = {'learning_rate': [0.01, 0.05, 0.1, 0.3],
'max_leaf_nodes': [7, 15, 31, 63],
'min_samples_leaf': [5, 20, 50, 100]}
dists = {'learning_rate': loguniform(0.005, 0.4),
'max_leaf_nodes': randint(4, 80),
'min_samples_leaf': randint(3, 120)}
est = HistGradientBoostingClassifier(random_state=42)
for label, search in [
('grid, 64 points', GridSearchCV(est, grid, cv=5,
scoring='roc_auc')),
('random, 25 draws', RandomizedSearchCV(est, dists, n_iter=25, cv=5,
scoring='roc_auc',
random_state=0))]:
t = time.time()
search.fit(X_tr, y_tr)
print('%-18s best %.4f in %5.1fs (%d fits)'
% (label, search.best_score_, time.time() - t,
len(search.cv_results_['params']) * 5))
random, 25 draws best 0.8160 in 28.4s (125 fits)
# Two parameters, only one of which matters. A 5 x 5 grid tries five
# distinct values of each. Twenty-five random draws try twenty-five.
rng = np.random.default_rng(0)
print('%-16s %22s %22s' % ('', 'distinct values tried', 'of the one that matters'))
for n in [9, 16, 25, 36]:
side = int(round(n ** 0.5))
print('%-16s %22d %22d'
% ('grid %dx%d' % (side, side), n, side))
print('%-16s %22d %22d' % ('random %d' % n, n, n))
grid 3x3 9 3
random 9 9 9
grid 4x4 16 4
random 16 16 16
grid 5x5 25 5
random 25 25 25
grid 6x6 36 6
random 36 36 36
The argument in one sentence
A grid spends its budget trying the same few values of the parameter that matters, over and over, paired with values of parameters that do not. Random search spends the same budget on distinct values of everything. Since you rarely know in advance which parameters matter, random search is the better default, and it lets you stop at any point rather than at a multiple of the grid size.
Day 1 takeaway
Find out which hyperparameters actually move your score before you search, and search those. Grid cost grows exponentially in the number of parameters and wastes most of its budget on repeats; preferRandomizedSearchCV with distributions, and use loguniform for anything that spans orders of magnitude.