Diagnosing Stalled Validation Loss in Keras: EarlyStopping and restore_best_weights Done Right
Validation loss stalls while training loss drops, and EarlyStopping hands you the wrong weights. A diagnostic guide to the causes, checks, and fixes — starting with restore_best_weights.
04 Sept 2025, 21:25 UTC

The recognizable condition
Your Keras model's training loss keeps dropping epoch after epoch, but validation loss flatlines — or worse, creeps upward. You added EarlyStopping to handle this, yet the final model performs noticeably worse than the best epoch you saw scroll past in the logs. That gap between "best observed" and "what you actually deployed" is almost always a callback configuration problem, not a model problem.
The fix usually takes one line: restore_best_weights=True. But knowing why training stalled in the first place determines whether that line is enough or just a bandage.
Cause and diagnostic table
| Symptom in history | Likely cause | First thing to check |
|---|---|---|
| val_loss plateaus, train_loss still falling | Overfitting or learning rate too high | Gap between the two curves; regularization |
| EarlyStopping fires, final model is worse than best epoch | restore_best_weights=False (the default) | Callback arguments |
| EarlyStopping fires within a few epochs on a jumpy metric | Noisy validation set, patience too low | Validation set size and shuffle |
| val_loss never improves at all | Data leakage, label noise, or broken pipeline | Split logic and a tiny overfit sanity test |
Ordered checks
- Plot or print the history. Inspect
history.history['val_loss']alongsidehistory.history['loss']. A plateau or U-shape in validation loss while training loss declines confirms stagnation, not a logging bug. - Verify the callback configuration. Check
monitor(is it actually'val_loss'and does that metric exist in your logs?),patience, andrestore_best_weights. The default for the last one isFalse, which silently hands you the final epoch's weights. - Check your validation split. If you used
validation_split=0.2on unshuffled, ordered data (e.g., sorted by class or time), the split may be unrepresentative or leaky, producing misleading stop signals. Prefer an explicit, shuffledtrain_test_splitor atf.datapipeline with shuffling before splitting. - Confirm version support.
restore_best_weightsexists in TensorFlow/Keras 2.x (2.3+; this guide assumes 2.13 or newer). On older versions the argument raises aTypeError. Check withpython -c "import tensorflow as tf; print(tf.__version__)"in the environment that runs training.
Fixes tied to findings
If the final model is worse than the best epoch: enable weight restoration and pair it with a learning-rate schedule so the model gets a chance to escape the plateau before stopping:
from tensorflow import keras
callbacks = [
keras.callbacks.EarlyStopping(
monitor='val_loss',
patience=5,
restore_best_weights=True,
),
keras.callbacks.ReduceLROnPlateau(
monitor='val_loss',
factor=0.5,
patience=2,
min_lr=1e-6,
),
]
history = model.fit(
x_train, y_train,
validation_data=(x_val, y_val),
epochs=100,
callbacks=callbacks,
)Order the ReduceLROnPlateau patience below the EarlyStopping patience; otherwise training halts before the rate ever drops.
If stopping happens too early on a noisy metric: raise patience (5–10 is a common range) and enlarge or reshuffle the validation set. A tiny validation set produces a jumpy metric that triggers false stops.
If the train/validation gap is large and growing: add regularization — dropout layers, or L2 weight decay via keras.regularizers.l2(1e-4) on dense/conv layers — rather than only tuning callbacks. Callbacks decide when to stop; regularization changes whether the plateau happens at all.
Verifying the fix
After training, compare the callback's recorded best against the history:
early_stop = callbacks[0]
print(early_stop.best) # best monitored value seen
print(min(history.history['val_loss'])) # should matchThe two values should be equal. Then evaluate the restored model on the validation set; its loss should match early_stop.best (up to batch-ordering noise). A quick end-to-end sanity check: deliberately overfit a small dense network on a subset of MNIST with patience=2, restore_best_weights=True and confirm the returned model scores at the minimum validation loss, not the last epoch's.
Limitations
restore_best_weightsonly restores weights if the monitored metric improved at least once after training began. Ifval_lossnever beats its first-epoch value, you keep the final weights regardless.- Restoration happens in memory at the end of training; it does not retroactively change checkpoints written by
ModelCheckpointunless that callback also usessave_best_only=True.
When to escalate
If validation loss still refuses to improve after callback tuning, learning-rate reduction, and regularization, the problem is upstream: audit for label noise and train/validation leakage, try overfitting a single small batch (the model should reach near-zero loss — if it can't, the pipeline or architecture is broken), and consider switching optimizers, e.g., from Adam to AdamW for decoupled weight decay. Callback configuration can't rescue a broken data pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.