Skip to content

Hyperparameter Tuning with KerasTuner

What you’ll learn

  • what a hyperparameter is, and why it’s different from a parameter your model learns via backpropagation
  • how to define a search space with KerasTuner’s hp.Inthp.Int, hp.Floathp.Float, hp.Booleanhp.Boolean, and hp.Choicehp.Choice
  • the difference between the RandomSearch, Hyperband, and BayesianOptimization tuners
  • the tuner workflow: search, query the best hyperparameters, then retrain a final model
  • why automated tuning can quietly overfit to your validation set
  • how model ensembling — averaging several models’ predictions — squeezes out extra accuracy

Hyperparameters vs. parameters

Every model you’ve built so far involved decisions that aren’t learned by gradient descent: how many layers to stack, how many units per layer, which activation function, how much dropout, which optimizer. These architecture-level decisions are called hyperparameters, to distinguish them from the parameters (weights) a network learns automatically while training.

Experienced engineers build intuition over time about what tends to work, but that intuition is never optimal — your first guess is almost always a little worse than the best possible configuration. Tweaking hyperparameters by hand and retraining over and over is exactly the kind of repetitive, mechanical search that’s better left to a machine. That’s what KerasTuner automates.

diagram The KerasTuner search loop mermaid
A tuner repeatedly samples a configuration, builds and trains a model, and uses the validation score to decide what to try next.

Defining a search space

Instead of hardcoding a value like units=32units=32, you sample it from a range. A model-building function takes an hphp object and returns a compiled model — every place you’d normally write a fixed number, you ask hphp for one instead:

A KerasTuner model-building function
import keras_tuner as kt
import tensorflow as tf
 
def build_model(hp):
    units = hp.Int(name="units", min_value=16, max_value=64, step=16)
    optimizer = hp.Choice(name="optimizer", values=["rmsprop", "adam"])
 
    model = tf.keras.Sequential([
        tf.keras.layers.Dense(units, activation="relu"),
        tf.keras.layers.Dense(10, activation="softmax"),
    ])
    model.compile(
        optimizer=optimizer,
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model
A KerasTuner model-building function
import keras_tuner as kt
import tensorflow as tf
 
def build_model(hp):
    units = hp.Int(name="units", min_value=16, max_value=64, step=16)
    optimizer = hp.Choice(name="optimizer", values=["rmsprop", "adam"])
 
    model = tf.keras.Sequential([
        tf.keras.layers.Dense(units, activation="relu"),
        tf.keras.layers.Dense(10, activation="softmax"),
    ])
    model.compile(
        optimizer=optimizer,
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )
    return model

hp.Inthp.Int and hp.Choicehp.Choice are two of several hyperparameter types available — hp.Floathp.Float and hp.Booleanhp.Boolean cover continuous values and on/off switches. After KerasTuner samples a value, it’s just a regular Python number or string inside build_model()build_model() — there’s no special “hyperparameter type” to worry about once you’re past the hp.*hp.* calls. If you’d rather organize this as a class (handy when you want constructor arguments like num_classesnum_classes), you can subclass kt.HyperModelkt.HyperModel and define a build(self, hp)build(self, hp) method instead — same logic, more structure.

Choosing a tuner

A tuner is a loop that repeatedly picks a hyperparameter configuration, builds and trains a model with it, and records how well it did. KerasTuner ships three built-in strategies for picking the next configuration to try:

  • RandomSearch — samples configurations uniformly at random. Simple, and a surprisingly strong baseline.
  • Hyperband — trains many configurations for just a few epochs, throws away the worst performers, and gives the survivors more epochs — like a tournament bracket. Much cheaper than training everything to completion.
  • BayesianOptimization — builds a probabilistic model of “which hyperparameters tend to score well,” and uses it to make smarter guesses about what to try next, based on every trial so far.
Setting up a BayesianOptimization tuner
tuner = kt.BayesianOptimization(
    build_model,
    objective="val_accuracy",   # always tune on a validation metric, never the test set
    max_trials=100,             # how many configurations to try before stopping
    executions_per_trial=2,     # train each configuration twice and average, to reduce noise
    directory="mnist_kt_test",
    overwrite=True,
)
Setting up a BayesianOptimization tuner
tuner = kt.BayesianOptimization(
    build_model,
    objective="val_accuracy",   # always tune on a validation metric, never the test set
    max_trials=100,             # how many configurations to try before stopping
    executions_per_trial=2,     # train each configuration twice and average, to reduce noise
    directory="mnist_kt_test",
    overwrite=True,
)

You can preview the space before running anything with tuner.search_space_summary()tuner.search_space_summary() — it prints every hyperparameter, its type, and its allowed range:

text
>>> tuner.search_space_summary()
Search space summary
Default search space size: 2
units (Int)
{"min_value": 16, "max_value": 64, "step": 16, ...}
optimizer (Choice)
{"values": ["rmsprop", "adam"], ...}
text
>>> tuner.search_space_summary()
Search space summary
Default search space size: 2
units (Int)
{"min_value": 16, "max_value": 64, "step": 16, ...}
optimizer (Choice)
{"values": ["rmsprop", "adam"], ...}

tuner.search()tuner.search() accepts the same arguments as fit()fit() — it just calls fit()fit() once per trial. Use a generous number of epochs together with an EarlyStoppingEarlyStopping callback, since you don’t know in advance how long each configuration needs:

Launching the hyperparameter search
callbacks = [tf.keras.callbacks.EarlyStopping(monitor="val_loss", patience=5)]
 
tuner.search(
    x_train, y_train,
    batch_size=128,
    epochs=100,
    validation_data=(x_val, y_val),
    callbacks=callbacks,
    verbose=2,
)
Launching the hyperparameter search
callbacks = [tf.keras.callbacks.EarlyStopping(monitor="val_loss", patience=5)]
 
tuner.search(
    x_train, y_train,
    batch_size=128,
    epochs=100,
    validation_data=(x_val, y_val),
    callbacks=callbacks,
    verbose=2,
)

Once the search finishes, ask for the top configurations and either retrain fresh models with them (recommended, using a higher patience and the full training set) or take the shortcut of reloading the best weights saved during the search:

Querying the best hyperparameters
top_n = 4
best_hps = tuner.get_best_hyperparameters(top_n)   # list of HyperParameters objects
best_hp = best_hps[0]
 
# Rebuild and retrain a fresh model with the winning configuration
model = build_model(best_hp)
model.fit(x_train_full, y_train_full, batch_size=128, epochs=30)
 
# Or, the shortcut: reload the tuner's own best-scoring models directly
best_models = tuner.get_best_models(top_n)
Querying the best hyperparameters
top_n = 4
best_hps = tuner.get_best_hyperparameters(top_n)   # list of HyperParameters objects
best_hp = best_hps[0]
 
# Rebuild and retrain a fresh model with the winning configuration
model = build_model(best_hp)
model.fit(x_train_full, y_train_full, batch_size=128, epochs=30)
 
# Or, the shortcut: reload the tuner's own best-scoring models directly
best_models = tuner.get_best_models(top_n)

Crafting the right search space

Hyperparameter tuning is automation, not magic. Search spaces grow combinatorially — add a third hyperparameter with 4 choices to the example above and you’ve tripled the number of configurations to try. It’s not worth turning every decision into a hyperparameter; you still need to hand-pick which knobs are worth searching. What tuning buys you is a shift in what you spend your time on: instead of micro-decisions (“what number of units for this layer?”), you get to reason about higher-level architecture choices (“should this model use residual connections at all?“) — and those higher-level decisions tend to generalize much better across different datasets and problems.

Model ensembling: combine, don’t just pick

Once you have several good models, you can often do better than any single one of them by ensembling: averaging their predictions together.

The idea traces back to the parable of the blind men and the elephant — each man touches a different part (a leg, the trunk, an ear) and comes away with a different, incomplete description. No single one of them is right, but pooling their observations gets much closer to the truth. Each of your trained models is like one of those blind men: it looked at the data from a slightly different angle (different architecture, different random initialization) and captured part of the picture but not all of it.

A simple average ensemble
preds_a = model_a.predict(x_val)
preds_b = model_b.predict(x_val)
preds_c = model_c.predict(x_val)
preds_d = model_d.predict(x_val)
 
# Equal-weight average - works well if all four models are roughly equally good
final_preds = 0.25 * (preds_a + preds_b + preds_c + preds_d)
A simple average ensemble
preds_a = model_a.predict(x_val)
preds_b = model_b.predict(x_val)
preds_c = model_c.predict(x_val)
preds_d = model_d.predict(x_val)
 
# Equal-weight average - works well if all four models are roughly equally good
final_preds = 0.25 * (preds_a + preds_b + preds_c + preds_d)

If one model is clearly weaker than the others, a plain average lets it drag down the result. A weighted average, with weights tuned on validation data (giving stronger models more say), is usually a better default:

A weighted ensemble
# Weights found empirically on validation data (e.g. via random search)
final_preds = 0.5 * preds_a + 0.25 * preds_b + 0.1 * preds_c + 0.15 * preds_d
A weighted ensemble
# Weights found empirically on validation data (e.g. via random search)
final_preds = 0.5 * preds_a + 0.25 * preds_b + 0.1 * preds_c + 0.15 * preds_d

The key ingredient is diversity. If every model in your ensemble is biased in the same way — say, four copies of the same architecture trained from different random seeds — the ensemble barely beats any single member, because they all make similar mistakes. Ensembling genuinely different approaches (a tree-based model alongside a neural net, for example) tends to help far more, even when one of them scores noticeably lower on its own — it may still be capturing signal the others miss entirely.

Mini-checkpoint

  • Hyperparameters (architecture choices) are tuned by search; parameters (weights) are tuned by gradient descent.
  • build_model(hp)build_model(hp) + a tuner (RandomSearchRandomSearch, HyperbandHyperband, or BayesianOptimizationBayesianOptimization) replaces hand-tuning with a systematic search over objective="val_..."objective="val_...".
  • Always tune against validation data, and keep a separate test set you never touch during tuning — validation-set overfitting is a real risk.
  • Ensembling works best when the models being combined are genuinely diverse, not just differently-seeded copies of the same architecture.

🧪 Try It Yourself

Exercise 1 – Sample values from a search space

Exercise 2 – Count the size of a search space

Exercise 3 – Weighted ensembling of model predictions

Next

Continue to Mixed Precision and Multi-GPU Training — tuning finds the best architecture; the next lever is making each individual training run faster, so you can afford to try more configurations in the same amount of time.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did