By now you have used settings without naming them: the test_size of a split, the alpha of Ridge, the learning rate in the training loop of Session 1. Nobody told the model these numbers. We chose them. That group of choices has its own name, hyperparameters, and getting them right is often the difference between a mediocre model and a good one.

This session finishes Part I. You will learn what hyperparameters are, how three of the most common ones (learning rate, batch size and epochs) change training, how to spot when you have trained too long, and how to search for good values without fooling yourself. It builds on the scoring tools from Session 4.

Parameters versus hyperparameters

Two sentences to keep apart for the rest of the course:

  • Parameters are the numbers the model learns from data: the weights and biases. Training changes them.
  • Hyperparameters are the settings you choose before training starts. They control how training happens or how flexible the model is. The model never changes them.
Who sets it Changes during training? Example
Parameter The training process Yes The slope ww of a line, a neural network's weights
Hyperparameter You No Learning rate, alpha, tree depth, number of epochs

A helpful picture is a recipe. The ingredients' final proportions that come out of cooking are like parameters. The oven temperature and cooking time you pick before you start are hyperparameters.

The big three of training

Learning rate

You have seen the gradient-descent update several times already:

Gradient-descent update
w←w−η ∂L∂ww \leftarrow w - \eta\,\frac{\partial L}{\partial w}

η\eta ("eta"), also written α\alpha in some books, is the learning rate: how big a step to take in the downhill direction. It is usually the single most influential hyperparameter in neural-network training.

Too small and training crawls. Too big and every step overshoots the bottom and the loss bounces or explodes. A good value descends quickly and settles.

Three gradient descent curves showing a low learning rate converging slowly, a good learning rate converging well and a high learning rate overshooting
The effect of the learning rate on convergence, drawn as an illustration.

Here it is for real, on the simplest bowl-shaped problem, f(w)=(w−3)2f(w) = (w-3)^2, whose minimum is at w=3w=3:

Three learning rates on a bowl
import numpy as np
import matplotlib.pyplot as plt

# minimise f(w) = (w - 3)^2, whose slope is 2(w - 3). The true minimum is w = 3.
def descend(lr, steps=25, w=-4.0):
    path = [w]
    for _ in range(steps):
        w = w - lr * 2 * (w - 3)
        path.append(w)
    return np.array(path)

fig, ax = plt.subplots(figsize=(6.6, 3.8))
for lr, color, label in [(0.02, "#F59E0B", "lr = 0.02  too small"),
                         (0.4,  "#16A34A", "lr = 0.4   good"),
                         (1.05, "#DC2626", "lr = 1.05  too big")]:
    p = descend(lr)
    ax.plot((p - 3) ** 2, color=color, lw=2, label=label)
    print(f"{label:24s} loss after 25 steps: {(p[-1] - 3) ** 2:12.4f}")
ax.set_yscale("log"); ax.set_xlabel("step"); ax.set_ylabel("loss (log scale)")
ax.legend(frameon=False); ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
Output
lr = 0.02  too small     loss after 25 steps:       6.3644
lr = 0.4   good          loss after 25 steps:       0.0000
lr = 1.05  too big       loss after 25 steps:    5752.1518
Loss curves for three learning rates on a simple bowl-shaped problem
Too small crawls, a good rate drops fast, too big overshoots the minimum and the loss explodes.

After 25 steps, the cautious rate (0.02) is still at loss 6.4, the good rate (0.4) is down to 0.0000, and the big rate (1.05) has diverged to 5,752. Each step overshoots the minimum by more than it started with, so the error grows instead of shrinking. If your training loss ever goes up and up or turns into nan, suspect the learning rate first.

Batch size

Real datasets are too big to push through the model all at once, so training uses mini-batches. The batch size is how many rows are processed before one parameter update.

  • Small batches: many updates per pass and noisy gradients. Cheap on memory.
  • Large batches: few updates per pass and smooth gradients. Needs more memory.
  • Batch size 1 is true "stochastic" gradient descent, and using the whole dataset is "batch" gradient descent. Session 7 compares them.

The number of updates per pass follows from simple arithmetic:

Updates per epoch
updates per epoch=⌈NB⌉\text{updates per epoch} = \left\lceil \frac{N}{B} \right\rceil

NN is the number of training rows and BB the batch size. The brackets ⌈ ⌉\lceil\,\rceil mean "round up", because the last batch may be smaller.

Same data, three batch sizes
import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.preprocessing import StandardScaler

X, y = load_diabetes(return_X_y=True)
X = StandardScaler().fit_transform(X)
y = (y - y.mean()) / y.std()
n = len(X)

def train(batch_size, epochs=20, lr=0.01, seed=0):
    rng = np.random.default_rng(seed)
    w = np.zeros(X.shape[1]); b = 0.0; updates = 0
    for _ in range(epochs):
        order = rng.permutation(n)
        for i in range(0, n, batch_size):
            idx = order[i:i + batch_size]
            err = y[idx] - (X[idx] @ w + b)
            w += lr * 2 * (X[idx].T @ err) / len(idx)     # gradient step on this mini-batch
            b += lr * 2 * err.mean()
            updates += 1
    return np.mean((y - (X @ w + b)) ** 2), updates

print("batch size   updates in 20 epochs   final loss")
for bs in [1, 32, 353]:
    loss, upd = train(bs)
    print(f"{bs:8d}     {upd:12d}           {loss:.4f}")
Output
batch size   updates in 20 epochs   final loss
       1             8840           0.5148
      32              280           0.4859
     353               40           0.5249

With 353 training rows, batch size 1 makes 8,840 updates in 20 passes, batch size 32 makes 280, and the full batch only 40. In this small test the middle option reached the lowest loss (0.486). It is a very common pattern: batch sizes of 16, 32 or 64 are popular defaults because they balance noise, speed and memory.

Epochs

One epoch is one complete pass through the training data. The number of epochs is how many times the model gets to see every row. This hyperparameter links straight to Session 3's two failures:

  • Too few epochs: the model has not learned the pattern yet. It does badly on training and validation data (underfitting, high bias). Train longer.
  • The right number: training and validation error are both low. This is the sweet spot.
  • Too many epochs: the model starts memorising the training rows. Training loss keeps falling while validation loss turns round and climbs (overfitting, high variance).

Let's watch it happen. We train a deliberately oversized small neural network (two layers of 128 neurons, on only about 300 rows) for 300 epochs and record both losses after every epoch.

Train for too long on purpose
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler

X, y = load_diabetes(return_X_y=True)
X = StandardScaler().fit_transform(X)
y = (y - y.mean()) / y.std()
X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.3, random_state=1)

# a deliberately over-sized network (two layers of 128) on only ~300 rows
net = MLPRegressor(hidden_layer_sizes=(128, 128), learning_rate_init=0.003, alpha=0.0, random_state=0)
train_loss, val_loss = [], []
for epoch in range(300):
    net.partial_fit(X_tr, y_tr)                              # one pass through the training data
    train_loss.append(np.mean((y_tr - net.predict(X_tr)) ** 2))
    val_loss.append(np.mean((y_va - net.predict(X_va)) ** 2))

best = int(np.argmin(val_loss))
print(f"best epoch: {best + 1}   validation loss there: {val_loss[best]:.3f}")
print(f"at epoch 300: train loss {train_loss[-1]:.3f}, validation loss {val_loss[-1]:.3f}")

fig, ax = plt.subplots(figsize=(6.6, 3.8))
ax.plot(train_loss, color="#2563EB", lw=2, label="training loss")
ax.plot(val_loss, color="#F59E0B", lw=2, label="validation loss")
ax.axvline(best, color="#16A34A", ls="--", lw=1.5, label=f"best epoch ({best + 1})")
ax.set_xlabel("epoch"); ax.set_ylabel("loss (MSE)")
ax.legend(frameon=False); ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
Output
best epoch: 7   validation loss there: 0.479
at epoch 300: train loss 0.000, validation loss 0.769
Training and validation loss over 300 epochs with the best epoch marked
Training loss keeps falling forever. Validation loss bottoms out and then climbs: that is the moment to stop.

The best validation loss, 0.479, comes at epoch 7. By epoch 300 the training loss has fallen to 0.000, a perfect memorisation of the training rows, while the validation loss has climbed to 0.769, much worse than at epoch 7. Same model, same data. All that changed was how long we trained.

The remedy is early stopping: watch the validation loss, and stop when it has not improved for a set number of epochs (called patience), then keep the best weights. It is free, it works, and it is one of the most useful habits in deep learning.

PYTHON
from tensorflow.keras.callbacks import EarlyStopping

stop = EarlyStopping(monitor="val_loss", patience=5, restore_best_weights=True)
model.fit(X_train, y_train, validation_split=0.2,
          epochs=200, batch_size=32, callbacks=[stop])

The Deep Learning textbook by Goodfellow, Bengio and Courville calls the learning rate "perhaps the most important hyperparameter", and says that if you can tune only one, tune that one.Deep Learning, Goodfellow, Bengio and Courville, 2016

Quick check

During training, your training loss keeps falling but your validation loss started rising 20 epochs ago. What should you do?

Rising validation loss with falling training loss is the signature of overfitting. The best model is the one from the epoch where validation loss was lowest, and early stopping automates exactly that.

Other hyperparameters you will meet

A quick catalogue. Do not memorise it. Skim so the names ring a bell when each shows up again in its own session.

For neural networks (Sessions 9, 10, 16 to 20):

  • Hidden layers and neurons per layer: the depth and width of the network.
  • Activation function: ReLU, sigmoid, tanh, softmax (Session 6).
  • Optimizer: SGD, Adam, RMSprop, Adagrad (Session 7).
  • Momentum: smooths updates so training does not zig-zag.
  • Learning-rate decay: shrink the learning rate over time (Session 8).
  • Dropout rate: the fraction of neurons switched off at random during training.
  • Weight decay / L1 / L2: the penalties from Session 3.

For classical models:

Model Key hyperparameters What they do
Logistic regression / SVM C, penalty C is the inverse of regularization strength: smaller means stronger
SVM kernel, gamma The shape of the boundary, and how far one point's influence reaches
K-nearest neighbours n_neighbors, metric, weights How many neighbours vote, and how distance is measured
Decision tree / forest / boosting max_depth, n_estimators, min_samples_leaf, max_features How large and how many trees

Logistic regression, mentioned there, is the classification cousin of linear regression. It takes the same straight-line score and squeezes it through an S-shaped curve so the output lands between 0 and 1, readable as a probability:

The sigmoid (logistic) function
σ(z)=11+e−z\sigma(z) = \frac{1}{1 + e^{-z}}

For very negative zz the output is close to 0, for very positive zz it is close to 1, and σ(0)=0.5\sigma(0) = 0.5. Logistic regression computes z=w1x1+⋯+bz = w_1x_1 + \dots + b and returns σ(z)\sigma(z) as the probability of the positive class.

Linear regression drawing a straight line through continuous data beside logistic regression drawing an S-shaped curve for two classes
Linear regression predicts a number with a line. Logistic regression predicts a probability with an S-curve.
Support Vector Machine finding a decision boundary that separates two classes with the widest margin
A preview of Session 14: a support vector machine looks for the boundary with the widest margin.
PYTHON
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier

LogisticRegression(C=1.0, penalty="l2", max_iter=1000)       # C = inverse regularization strength
SVC(kernel="rbf", C=1.0, gamma="scale")                      # kernel, C, gamma
KNeighborsClassifier(n_neighbors=5, metric="minkowski", weights="distance")
RandomForestClassifier(n_estimators=200, max_depth=8, min_samples_leaf=2, max_features="sqrt")

Everything inside those brackets is a hyperparameter: set before fit, never learned.

How to tune without cheating

There is a trap here, and it is the same one from Session 1. If you try 50 settings, score each on the test set and keep the best, you have used the test set to choose, so its score is no longer an honest surprise. You have leaked test information into your decisions.

The fix is cross-validation (CV) on the training data only. Split the training rows into kk folds (often 5). Train on 4 folds, validate on the fifth, rotate so every fold gets a turn as the validation set, and average the scores.

k-fold cross-validation score
CV=1k∑j=1kscorej\text{CV} = \frac{1}{k}\sum_{j=1}^{k} \text{score}_j

scorej\text{score}_j is the metric (for example R²) measured on fold jj when the model was trained on the other k−1k-1 folds. The spread across folds (the standard deviation) also matters: it tells you how stable the estimate is.

Five-fold cross-validation
from sklearn.datasets import load_diabetes
from sklearn.linear_model import Ridge
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_diabetes(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

model = make_pipeline(StandardScaler(), Ridge(alpha=10))
cv = KFold(n_splits=5, shuffle=True, random_state=0)
scores = cross_val_score(model, X_tr, y_tr, cv=cv, scoring="r2")

print("R² on each of the 5 folds:", scores.round(3))
print(f"mean {scores.mean():.3f}  +/- {scores.std():.3f}")
Output
R² on each of the 5 folds: [0.504 0.504 0.573 0.431 0.469]
mean 0.496  +/- 0.047

The five folds scored between 0.431 and 0.573, a spread of 0.047. One single split could easily have landed on 0.43 or 0.57 by luck. That is why we trust a CV average more than one split, and why Session 4's advice about not crowning a winner on a 0.01 difference matters.

Grid search

Grid search tries every combination from a grid you define, scoring each with cross-validation. It is exhaustive and simple. Here we tune two things together on a Ridge model: the polynomial degree of the features and the penalty alpha.

GridSearchCV and RandomizedSearchCV
import pandas as pd
from scipy.stats import loguniform
from sklearn.datasets import load_diabetes
from sklearn.linear_model import Ridge
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler

X, y = load_diabetes(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

pipe = Pipeline([("poly", PolynomialFeatures(include_bias=False)),
                 ("scale", StandardScaler()),
                 ("ridge", Ridge())])

# Grid search: every combination, 3 x 5 = 15, each scored with 5-fold CV = 75 fits
grid = GridSearchCV(pipe, {"poly__degree": [1, 2, 3], "ridge__alpha": [0.1, 1, 10, 100, 1000]},
                    cv=5, scoring="r2").fit(X_tr, y_tr)
print("GRID   best:", grid.best_params_, "| CV R²:", round(grid.best_score_, 3),
      "| test R²:", round(grid.score(X_te, y_te), 3))

top = pd.DataFrame(grid.cv_results_)[["param_poly__degree", "param_ridge__alpha", "mean_test_score"]]
print(top.sort_values("mean_test_score", ascending=False).head(4).round(3).to_string(index=False))

# Random search: 10 random draws instead of all combinations
rand = RandomizedSearchCV(pipe, {"poly__degree": [1, 2, 3], "ridge__alpha": loguniform(1e-1, 1e4)},
                          n_iter=10, cv=5, scoring="r2", random_state=0).fit(X_tr, y_tr)
print("\nRANDOM best:", {k: (round(v, 1) if isinstance(v, float) else v) for k, v in rand.best_params_.items()},
      "| CV R²:", round(rand.best_score_, 3), "| test R²:", round(rand.score(X_te, y_te), 3))
Output
GRID   best: {'poly__degree': 1, 'ridge__alpha': 10} | CV R²: 0.454 | test R²: 0.457
param_poly__degree param_ridge__alpha  mean_test_score
                 1                 10            0.454
                 1                  1            0.451
                 1                100            0.451
                 1                0.1            0.450

RANDOM best: {'poly__degree': 1, 'ridge__alpha': 8.3} | CV R²: 0.454 | test R²: 0.457

Three results are worth reading slowly.

  • The winner uses degree 1, a plain straight line, with alpha = 10. The fancier polynomial features did not help, they overfit. Tuning is not about picking the most complex model, it is about finding the right amount.
  • The CV score of the best model (0.454) is almost the same as its test score (0.457). That agreement is the sign of an honest process: the test set was used exactly once, at the end.
  • That grid had 3 x 5 = 15 combinations, each trained 5 times: 75 fits. A grid grows fast. Add a third hyperparameter with 5 values and it is 375 fits.

Random search

Random search samples a fixed number of random combinations instead. It looks less thorough but it is often better, because usually only one or two hyperparameters really matter, and random sampling tests many different values of each one rather than repeating the same few. Above, 10 random draws (about two-thirds of the grid's work) found alpha = 8.3 and the same CV score of 0.454.

A third family, Bayesian optimisation (tools such as Optuna), uses the results of earlier trials to decide where to look next. It pays off when each trial is expensive, like training a big neural network.

Method Idea Best when
Grid search Try every combination Few hyperparameters, few values
Random search Try random combinations Many hyperparameters, limited time
Bayesian optimisation Learn from earlier trials Each trial is slow or costly
PYTHON
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.ensemble import RandomForestRegressor

grid = GridSearchCV(RandomForestRegressor(random_state=0),
                    param_grid={"n_estimators": [100, 200], "max_depth": [None, 5, 10]},
                    cv=5, scoring="r2")
grid.fit(X_train, y_train)
print(grid.best_params_, grid.best_score_)
# RandomizedSearchCV(estimator, param_distributions, n_iter=20, cv=5) samples instead

scikit-learn, the library behind most code in Part I, began in 2007 as a Google Summer of Code project by David Cournapeau. Today it is one of the most widely used machine learning libraries in Python.scikit-learn project history

Quick check

You tried 40 hyperparameter settings and kept the one with the best test-set score. What is the problem?

Once the test score influences your choice, the test set stops being unseen data. Cross-validation inside the training set lets you compare settings fairly, leaving the test set as a clean final check.

Try it yourself

  1. In learning_rate.py, try lr = 0.9 and lr = 1.0. What is special about exactly 1.0 on this problem? (Hint: look at the update formula.)
  2. In epochs.py, add alpha=0.5 to the MLPRegressor (that is its L2 penalty). Does the best epoch move? Does validation loss still climb?
  3. Extend the grid in grid_random.py with degree 4. What happens to the CV score? Why?
  4. Write a loop that fits a random forest with max_depth from 1 to 12 and plots training and validation R² against depth. Where does overfitting begin?

Part I complete: what you can do now

Stop for a second. You can set up an environment, clean and split data without leaking, train a line, fix its overfitting with Ridge and Lasso, score it with the right metric and tune its settings honestly. That is the same workflow a professional uses, only on small data.

Part II opens the engine room. Session 6 looks at what "wrong" means to a model (loss functions) and how curves enter the picture (activation functions). That is where neural networks begin to make sense.

Frequently asked questions

What is the difference between a parameter and a hyperparameter?

Parameters are learned from the data during training, like the weights of a neural network or the slope of a line. Hyperparameters are chosen by you before training, like the learning rate, batch size, number of epochs or tree depth. The model never changes its own hyperparameters.

How many epochs should I train for?

There is no fixed number. Train with a validation set, watch the validation loss, and use early stopping to halt when it stops improving. In our example the best epoch was 7 out of 300, but it depends on the model and the data.

What is a good batch size?

Common defaults are 16, 32 or 64. Smaller batches give noisier updates and use less memory, larger ones are smoother and faster per epoch on a GPU but need more memory. It also interacts with the learning rate, so if you change one, check the other.

Is grid search or random search better?

Grid search is fine when you have only a couple of hyperparameters with a few values each. Random search usually finds equally good settings with less work when there are many hyperparameters, because it explores more distinct values of each one.

Why can't I tune hyperparameters on the test set?

Because choosing the best of many settings by its test score makes the test score optimistic, since the test data has influenced your decision. Use cross-validation on the training data to tune, and keep the test set for one final check.