Learning Rate Scheduling
What you’ll learn
- why a single constant learning rate is always a compromise
- power scheduling, exponential scheduling, and piecewise constant scheduling
- performance scheduling with
ReduceLROnPlateauReduceLROnPlateau - the 1cycle schedule, and why Géron calls it out as the strongest option
Why schedule the learning rate at all?
Too high, and training diverges. Too low, and it converges but takes forever. Slightly too high, and it makes fast progress early on, then dances around the optimum without ever quite settling. A learning rate schedule sidesteps the whole trade-off: start large (fast early progress) and shrink it once progress slows down, reaching a better solution faster than any single constant rate could.
flowchart LR A["High LR early
(fast progress)"] --> B["LR decays
as training slows"] B --> C["Low LR late
(fine settling near optimum)"]
Get the base rate right before you schedule it
A schedule only decays a starting point — if that starting point is badly
wrong, no schedule will save the run. Chollet’s diagnostic for a training loss
that simply won’t move: don’t reach for a fancier optimizer first, tune the
learning rate and batch size, since they’re usually enough on their
own to get things moving. A learning rate of 1.01.0 on RMSprop can make training
overshoot so badly that accuracy gets stuck around 30–40%; dropping it to
1e-21e-2 on the exact same model and data can be the entire fix:
import numpy as np
import tensorflow as tf
np.random.seed(42)
X = np.random.rand(200, 20).astype("float32")
y = (X.sum(axis=1) > 10).astype("float32")
def train(learning_rate, epochs=5):
model = tf.keras.models.Sequential([
tf.keras.layers.Dense(32, activation="relu", input_shape=(20,)),
tf.keras.layers.Dense(1, activation="sigmoid"),
])
model.compile(
optimizer=tf.keras.optimizers.RMSprop(learning_rate),
loss="binary_crossentropy",
metrics=["accuracy"],
)
history = model.fit(X, y, epochs=epochs, batch_size=32, verbose=0)
return history.history["accuracy"][-1]
print("lr=1.0 final accuracy:", round(train(1.0), 2))
print("lr=1e-2 final accuracy:", round(train(1e-2), 2))import numpy as np
import tensorflow as tf
np.random.seed(42)
X = np.random.rand(200, 20).astype("float32")
y = (X.sum(axis=1) > 10).astype("float32")
def train(learning_rate, epochs=5):
model = tf.keras.models.Sequential([
tf.keras.layers.Dense(32, activation="relu", input_shape=(20,)),
tf.keras.layers.Dense(1, activation="sigmoid"),
])
model.compile(
optimizer=tf.keras.optimizers.RMSprop(learning_rate),
loss="binary_crossentropy",
metrics=["accuracy"],
)
history = model.fit(X, y, epochs=epochs, batch_size=32, verbose=0)
return history.history["accuracy"][-1]
print("lr=1.0 final accuracy:", round(train(1.0), 2))
print("lr=1e-2 final accuracy:", round(train(1e-2), 2))Once training actually gets started at a sane base rate, that’s when scheduling (below) starts paying off — squeezing out a better final result faster than a constant rate ever could.
Power scheduling
The learning rate becomes a function of the iteration number tt:
η(t) = η₀ / (1 + t/s)^cη(t) = η₀ / (1 + t/s)^c
It drops quickly at first, then more and more slowly. In Keras this is the
easiest schedule to wire up — just set decaydecay on the optimizer (Keras assumes
c = 1c = 1, and decaydecay is 1/s1/s):
import tensorflow as tf
optimizer = tf.keras.optimizers.legacy.SGD(learning_rate=0.01, decay=1e-4)import tensorflow as tf
optimizer = tf.keras.optimizers.legacy.SGD(learning_rate=0.01, decay=1e-4)Exponential scheduling
η(t) = η₀ · 0.1^(t/s)η(t) = η₀ · 0.1^(t/s) — the learning rate is slashed by a factor of 10 every
ss steps, a steadier decay than power scheduling’s fast-then-slow curve. Define
a schedule function and hand it to a LearningRateSchedulerLearningRateScheduler callback:
import tensorflow as tf
def exponential_decay(lr0, s):
def exponential_decay_fn(epoch):
return lr0 * 0.1 ** (epoch / s)
return exponential_decay_fn
exponential_decay_fn = exponential_decay(lr0=0.01, s=20)
lr_scheduler = tf.keras.callbacks.LearningRateScheduler(exponential_decay_fn)
# history = model.fit(X_train, y_train, epochs=20, callbacks=[lr_scheduler])import tensorflow as tf
def exponential_decay(lr0, s):
def exponential_decay_fn(epoch):
return lr0 * 0.1 ** (epoch / s)
return exponential_decay_fn
exponential_decay_fn = exponential_decay(lr0=0.01, s=20)
lr_scheduler = tf.keras.callbacks.LearningRateScheduler(exponential_decay_fn)
# history = model.fit(X_train, y_train, epochs=20, callbacks=[lr_scheduler])Piecewise constant scheduling
Use one learning rate for a stretch of epochs, then drop to a smaller one, and so on. Simple, but it takes some trial and error to pick the right rates and how long to hold each one:
import tensorflow as tf
def piecewise_constant_fn(epoch):
if epoch < 5:
return 0.01
elif epoch < 15:
return 0.005
else:
return 0.001
lr_scheduler = tf.keras.callbacks.LearningRateScheduler(piecewise_constant_fn)import tensorflow as tf
def piecewise_constant_fn(epoch):
if epoch < 5:
return 0.01
elif epoch < 15:
return 0.005
else:
return 0.001
lr_scheduler = tf.keras.callbacks.LearningRateScheduler(piecewise_constant_fn)Performance scheduling
Instead of a fixed formula, watch the validation loss (the same signal early stopping uses) and cut the learning rate whenever it stops improving:
import tensorflow as tf
lr_scheduler = tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=5)
# multiplies the learning rate by 0.5 whenever val_loss hasn't improved for 5 epochsimport tensorflow as tf
lr_scheduler = tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=5)
# multiplies the learning rate by 0.5 whenever val_loss hasn't improved for 5 epochsThe tf.keras.optimizers.schedules approach
tf.kerastf.keras also lets you attach a schedule object directly to the optimizer,
which updates the learning rate every step (not just every epoch) and gets saved
along with the model:
import tensorflow as tf
batch_size = 32
s = 20 * len(X_train) // batch_size # steps for ~20 epochs
learning_rate = tf.keras.optimizers.schedules.ExponentialDecay(0.01, s, 0.1)
optimizer = tf.keras.optimizers.SGD(learning_rate)import tensorflow as tf
batch_size = 32
s = 20 * len(X_train) // batch_size # steps for ~20 epochs
learning_rate = tf.keras.optimizers.schedules.ExponentialDecay(0.01, s, 0.1)
optimizer = tf.keras.optimizers.SGD(learning_rate)1cycle scheduling
Leslie Smith’s 1cycle schedule (2018) does something different from every
schedule above: it increases the learning rate from η₀η₀ up to a peak η₁η₁
during the first half of training, then decreases it back down to η₀η₀ during
the second half, finishing the last few epochs by dropping several orders of
magnitude further. Momentum is often varied in the opposite direction — starting
high, dipping in the middle, then climbing back up. On CIFAR10, Smith reported
reaching 91.9% validation accuracy in just 100 epochs with 1cycle, versus 90.3%
in 800 epochs the standard way — the same architecture, radically less training.
import tensorflow as tf
class OneCycleScheduler(tf.keras.callbacks.Callback):
def __init__(self, iterations, max_lr=1e-3, start_lr=None, last_iterations=None, last_lr=None):
self.iterations = iterations
self.max_lr = max_lr
self.start_lr = start_lr or max_lr / 10
self.last_iterations = last_iterations or iterations // 10 + 1
self.half_iteration = (iterations - self.last_iterations) // 2
self.last_lr = last_lr or self.start_lr / 1000
self.iteration = 0
def _interpolate(self, iter1, iter2, lr1, lr2):
return (lr2 - lr1) * (self.iteration - iter1) / (iter2 - iter1) + lr1
def on_batch_begin(self, batch, logs=None):
if self.iteration < self.half_iteration:
lr = self._interpolate(0, self.half_iteration, self.start_lr, self.max_lr)
elif self.iteration < 2 * self.half_iteration:
lr = self._interpolate(self.half_iteration, 2 * self.half_iteration, self.max_lr, self.start_lr)
else:
lr = self._interpolate(2 * self.half_iteration, self.iterations, self.start_lr, self.last_lr)
self.iteration += 1
self.model.optimizer.learning_rate = lrimport tensorflow as tf
class OneCycleScheduler(tf.keras.callbacks.Callback):
def __init__(self, iterations, max_lr=1e-3, start_lr=None, last_iterations=None, last_lr=None):
self.iterations = iterations
self.max_lr = max_lr
self.start_lr = start_lr or max_lr / 10
self.last_iterations = last_iterations or iterations // 10 + 1
self.half_iteration = (iterations - self.last_iterations) // 2
self.last_lr = last_lr or self.start_lr / 1000
self.iteration = 0
def _interpolate(self, iter1, iter2, lr1, lr2):
return (lr2 - lr1) * (self.iteration - iter1) / (iter2 - iter1) + lr1
def on_batch_begin(self, batch, logs=None):
if self.iteration < self.half_iteration:
lr = self._interpolate(0, self.half_iteration, self.start_lr, self.max_lr)
elif self.iteration < 2 * self.half_iteration:
lr = self._interpolate(self.half_iteration, 2 * self.half_iteration, self.max_lr, self.start_lr)
else:
lr = self._interpolate(2 * self.half_iteration, self.iterations, self.start_lr, self.last_lr)
self.iteration += 1
self.model.optimizer.learning_rate = lrVisualize it
Each schedule decays the learning rate very differently. Power scheduling drops fast then flattens out; exponential scheduling keeps slashing it by the same factor; 1cycle rises first, then falls, then collapses at the very end:
Mini-checkpoint
You’ve trained for a while and validation loss has plateaued for the last several epochs, but you don’t want to hand-pick a decay formula. Which callback fits best?
ReduceLROnPlateauReduceLROnPlateau— it watches validation loss directly and cuts the learning rate only when progress actually stalls.
🧪 Try It Yourself
Exercise 1 – Build an Exponential Decay Schedule
Exercise 2 – ReduceLROnPlateau Callback
Exercise 3 – A Piecewise Constant Schedule Function
Next
Continue to The Universal Workflow of Machine Learning — you now have a full toolkit for training deep nets; the next three pages zoom back out to the project-level workflow that toolkit fits into, before Phase 3 puts it all to work on real images.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
