Learning Rate Finder
Setting the learning rate for stochastic gradient descent (SGD) is crucially important when training neural network because it controls both the speed of convergence and the ultimate performance of the network. Set the learning too low and you could be twiddling your thumbs for quite some time as the parameters update very slowly. Set it too high and the updates will skip over optimal solutions, or worse the optimizer might not converge at all!
Leslie Smith from the U.S. Naval Research Laboratory presented a method for finding a good learning rate in a paper called “Cyclical Learning Rates for Training Neural Networks”. We implement this method in MXNet (with the Gluon API) and create a ‘Learning Rate Finder’ which you can use while training your own networks. We take a look at the central idea of the paper, cyclical learning rate schedules, in the ‘Advanced Learning Rate Schedules’ tutorial.
Simple Idea
Given an initialized network, a defined loss and a training dataset we take the following steps:
- Train one batch at a time (a.k.a. an iteration)
- Start with a very small learning rate (e.g. 0.000001) and slowly increase it every iteration
- Record the training loss and continue until we see the training loss diverge
We then analyse the results by plotting a graph of the learning rate against the training loss as seen below (taking note of the log scales).
As expected, for very small learning rates we don’t see much change in the loss as the parameter updates are negligible. At a learning rate of 0.001, we start to see the loss fall. Setting the initial learning rate here is reasonable, but we still have the potential to learn faster. We observe a drop in the loss up until 0.1 where the loss appears to diverge. We want to set the initial learning rate as high as possible before the loss becomes unstable, so we choose a learning rate of 0.
Epoch to iteration
Usually, our unit of work is an epoch (a full pass through the dataset) and the learning rate would typically be held constant throughout the epoch. With the Learning Rate Finder (and cyclical learning rate schedules) we are required to vary the learning rate every iteration. As such we structure our training code so that a single iteration can be run with a given learning rate. You can implement Learner as you wish. Just initialize the network, define the loss and trainer in init and keep your training logic for a single batch in iteration.
1 | import mxnet as mx |
We also adjust our DataLoader so that it continuously provides batches of data and doesn’t stop after a single epoch. We can then call iteration as many times as required for the loss to diverge as part of the Learning Rate Finder process. We implement a custom BatchSampler for this, that keeps returning random indices of samples to be included in the next batch. We use the CIFAR-10 dataset for image classification to test our Learning Rate Finder.
1 | from mxnet.gluon.data.vision import transforms |
Implementation
With preparation complete, we’re ready to write our Learning Rate Finder that wraps the Learner we defined above. We implement a find method for the procedure, and plot for the visualization. Starting with a very low learning rate as defined by lr_start we train one iteration at a time and keep multiplying the learning rate by lr_multiplier. We analyse the loss and continue until it diverges according to LRFinderStoppingCriteria (which is defined later on). You may also notice that we save the parameters and state of the optimizer before the process and restore afterwards. This is so the Learning Rate Finder process doesn’t impact the state of the model, and can be used at any point during training.
1 | from matplotlib import pyplot as plt |
You can define the LRFinderStoppingCriteria as you wish, but empirical testing suggests using a smoothed average gives a more consistent stopping rule (see smoothing). We stop when the smoothed average of the loss exceeds twice the initial loss, assuming there have been a minimum number of iterations (see min_iter).
1 | class LRFinderStoppingCriteria(): |
Usage
Using a Pre-activation ResNet-18 from the Gluon model zoo, we instantiate our Learner and fire up our Learning Rate Finder!
1 | ctx = mx.gpu() if mx.context.num_gpus() else mx.cpu() |
As discussed before, we should select a learning rate where the loss is falling (i.e. from 0.001 to 0.05) but before the loss starts to diverge (i.e. 0.1). We prefer higher learning rates where possible, so we select an initial learning rate of 0.05. Just as a test, we will run 500 epochs using this learning rate and evaluate the loss on the final batch. As we’re working with a single batch of 128 samples, the variance of the loss estimates will be reasonably high, but it will give us a general idea. We save the initialized parameters for a later comparison with other learning rates.
1 | learner.net.save_parameters("net.params") |
Iteration: 0, Loss: 2.785
Iteration: 100, Loss: 1.6653
Iteration: 200, Loss: 1.4891
Final Loss: 1.1812
We see a sizable drop in the loss from approx. 2.7 to 1.2.
And now we have a baseline, let’s see what happens when we train with a learning rate that’s higher than advisable at 0.5.
1 | net = mx.gluon.model_zoo.vision.resnet18_v2(classes=10) |
Iteration: 0, Loss: 2.6469
Iteration: 100, Loss: 1.9666
Iteration: 200, Loss: 1.6919
Final Loss: 1.366
We still observe a fall in the loss but aren’t able to reach as low as before.
And lastly, we see how the model trains with a more conservative learning rate of 0.005.
1 | net = mx.gluon.model_zoo.vision.resnet18_v2(classes=10) |
Iteration: 0, Loss: 2.605
Iteration: 100, Loss: 1.8621
Iteration: 200, Loss: 1.6316
Final Loss: 1.2919
Although we get quite similar results to when we set the learning rate at 0.05 (because we’re still in the region of falling loss on the Learning Rate Finder plot), we can still optimize our network faster using a slightly higher rate.
Program
1 | import mxnet as mx |
Learning Rate Schedules
Setting the learning rate for stochastic gradient descent (SGD) is crucially important when training neural networks because it controls both the speed of convergence and the ultimate performance of the network. One of the simplest learning rate strategies is to have a fixed learning rate throughout the training process. Choosing a small learning rate allows the optimizer find good solutions, but this comes at the expense of limiting the initial speed of convergence. Changing the learning rate over time can overcome this tradeoff.
Schedules define how the learning rate changes over time and are typically specified for each epoch or iteration (i.e. batch) of training. Schedules differ from adaptive methods (such as AdaDelta and Adam) because they:
- change the global learning rate for the optimizer, rather than parameter-wise learning rates
- don’t take feedback from the training process and are specified beforehand
In this tutorial, we visualize the schedules defined in mx.lr_scheduler, show how to implement custom schedules and see an example of using a schedule while training models. Since schedules are passed to mx.optimizer.Optimizer classes, these methods work with both Module and Gluon APIs.1
2
3
4
5
6
7from __future__ import print_function
import math
import matplotlib.pyplot as plt
import mxnet as mx
from mxnet.gluon import nn
from mxnet.gluon.data.vision import transforms
import numpy as np1
2
3
4
5
6
7
8def plot_schedule(schedule_fn, iterations=1500):
# Iteration count starting at 1
iterations = [i+1 for i in range(iterations)]
lrs = [schedule_fn(i) for i in iterations]
plt.scatter(iterations, lrs)
plt.xlabel("Iteration")
plt.ylabel("Learning Rate")
plt.show()Schedules
In this section, we take a look at the schedules in mx.lr_scheduler. All of these schedules define the learning rate for a given iteration, and it is expected that iterations start at 1 rather than 0. So to find the learning rate for the 100th iteration, you can call schedule(100).Stepwise Decay Schedule
One of the most commonly used learning rate schedules is called stepwise decay, where the learning rate is reduced by a factor at certain intervals. MXNet implements a FactorScheduler for equally spaced intervals, and MultiFactorScheduler for greater control. We start with an example of halving the learning rate every 250 iterations. More precisely, the learning rate will be multiplied by factor after the step index and multiples thereafter. So in the example below the learning rate of the 250th iteration will be 1 and the 251st iteration will be 0.5.1
2
3schedule = mx.lr_scheduler.FactorScheduler(step=250, factor=0.5)
schedule.base_lr = 1
plot_schedule(schedule)
Note: the base_lr is used to determine the initial learning rate. It takes a default value of 0.01 since we inherit from mx.lr_scheduler.LRScheduler, but it can be set as a property of the schedule. We will see later in this tutorial that base_lr is set automatically when providing the lr_schedule to Optimizer. Also be aware that the schedules in mx.lr_scheduler have state (i.e. counters, etc) so calling the schedule out of order may give unexpected results.
We can define non-uniform intervals with MultiFactorScheduler and in the example below we halve the learning rate after the 250th, 750th (i.e. a step length of 500 iterations) and 900th (a step length of 150 iterations). As before, the learning rate of the 250th iteration will be 1 and the 251th iteration will be 0.5.
1 | schedule = mx.lr_scheduler.MultiFactorScheduler(step=[250, 750, 900], factor=0.5) |
Polynomial Schedule
Stepwise schedules and the discontinuities they introduce may sometimes lead to instability in the optimization, so in some cases smoother schedules are preferred. PolyScheduler gives a smooth decay using a polynomial function and reaches a learning rate of 0 after max_update iterations. In the example below, we have a quadratic function (pwr=2) that falls from 0.998 at iteration 1 to 0 at iteration 1000. After this the learning rate stays at 0, so nothing will be learnt from max_update iterations onwards.
1 | schedule = mx.lr_scheduler.PolyScheduler(max_update=1000, base_lr=1, pwr=2) |
Note: unlike FactorScheduler, the base_lr is set as an argument when instantiating the schedule.
And we don’t evaluate at iteration=0 (to get base_lr) since we are working with schedules starting at iteration=1.
Custom Schedules
You can implement your own custom schedule with a function or callable class, that takes an integer denoting the iteration index (starting at 1) and returns a float representing the learning rate to be used for that iteration. We implement the Cosine Annealing Schedule in the example below as a callable class (see call method).
1 | class CosineAnnealingSchedule(): |
Using schedules
While training a simple handwritten digit classifier on the MNIST dataset, we take a look at how to use a learning rate schedule during training. Our demonstration model is a basic convolutional neural network. We start by preparing our DataLoader and defining the network.
As discussed above, the schedule should return a learning rate given an (1-based) iteration index.
1 | # Use GPU if one exists, else use CPU |
We then initialize our network (technically deferred until we pass the first batch) and define the loss.
1 | # Initialize the parameters with Xavier initializer |
We’re now ready to create our schedule, and in this example we opt for a stepwise decay schedule using MultiFactorScheduler. Since we’re only training a demonstration model for a limited number of epochs (10 in total) we will exaggerate the schedule and drop the learning rate by 90% after the 4th, 7th and 9th epochs. We call these steps, and the drop occurs after the step index. Schedules are defined for iterations (i.e. training batches), so we must represent our steps in iterations too.
1 | steps_epochs = [4, 7, 9] |
We create our Optimizer
and pass the schedule via the lr_scheduler
parameter. In this example we’re using Stochastic Gradient Descent.
1 | sgd_optimizer = mx.optimizer.SGD(learning_rate=0.03, lr_scheduler=schedule) |
And we use this optimizer (with schedule) in our Trainer and train for 10 epochs. Alternatively, we could have set the optimizer to the string sgd, and pass a dictionary of the optimizer parameters directly to the trainer using optimizer_params.
1 | trainer = mx.gluon.Trainer(params=net.collect_params(), optimizer=sgd_optimizer) |
Epoch: 1; Batch 1; Loss 2.304071; LR 0.030000
Epoch: 2; Batch 1; Loss 0.059640; LR 0.030000
Epoch: 3; Batch 1; Loss 0.072601; LR 0.030000
Epoch: 4; Batch 1; Loss 0.042228; LR 0.030000
Epoch: 5; Batch 1; Loss 0.025745; LR 0.003000
Epoch: 6; Batch 1; Loss 0.027391; LR 0.003000
Epoch: 7; Batch 1; Loss 0.048237; LR 0.003000
Epoch: 8; Batch 1; Loss 0.024213; LR 0.000300
Epoch: 9; Batch 1; Loss 0.008892; LR 0.000300
Epoch: 10; Batch 1; Loss 0.006875; LR 0.000030
We see that the learning rate starts at 0.03, and falls to 0.00003 by the end of training as per the schedule we defined.
Manually setting the learning rate: Gluon API only
When using the method above you don’t need to manually keep track of iteration count and set the learning rate, so this is the recommended approach for most cases. Sometimes you might want more fine-grained control over setting the learning rate though, so Gluon’s Trainer provides the set_learning_rate method for this.
We replicate the example above, but now keep track of the iteration_idx, call the schedule and set the learning rate appropriately using set_learning_rate. We also use schedule.base_lr to set the initial learning rate for the schedule since we are calling the schedule directly and not using it as part of the Optimizer.
1 | net = build_cnn() |
Epoch: 1; Batch 1; Loss 2.334119; LR 0.030000
Epoch: 2; Batch 1; Loss 0.178930; LR 0.030000
Epoch: 3; Batch 1; Loss 0.142640; LR 0.030000
Epoch: 4; Batch 1; Loss 0.041116; LR 0.030000
Epoch: 5; Batch 1; Loss 0.051049; LR 0.003000
Epoch: 6; Batch 1; Loss 0.027170; LR 0.003000
Epoch: 7; Batch 1; Loss 0.083776; LR 0.003000
Epoch: 8; Batch 1; Loss 0.082553; LR 0.000300
Epoch: 9; Batch 1; Loss 0.027984; LR 0.000300
Epoch: 10; Batch 1; Loss 0.030896; LR 0.000030
Once again, we see the learning rate start at 0.03, and fall to 0.00003 by the end of training as per the schedule we defined.
Program
1 | from __future__ import print_function |