An Intuitive Guide to Optimizers
Introduction
This blog will discuss optimizers, building up from first principles and walking through the evolution and development of optimizers.
Optimizer?
Feel free to skip this section if familiar with the basics at a high level, e.g. what is a loss function, what is an optimizer, etc. (If you know that our goal in training a neural network is to minimize then you should probably skip this section or spend ~15 seconds skimming it).
Brief In this blog, we'll represent a model as a parameter matrix , inputs to the model as a vector , and desired outputs as a vector . Then "running a model" on inputs will mean predicting some , and our ultimate goal in "training a model" is to find the parameters that lead to the best the most consistently. Since we want to find the "best ", we need some way to quantify "how wrong" we are: the loss function , which will compute some measure of how wrong we are based on the predicted outputs and the true outputs. We can represent as a function of quite naturally, and so our goal in training boils down to . This is an incredibly simple approximation of most neural networks but will serve as the running example throughout the blog for simplicity's sake.
In practice, a quite complex function: rarely convex and often depends on millions of parameters, and as a result, we can’t solve it analytically. Instead, it is common practice to iteratively update based on our training examples, across many steps, until it converges on what we think is the most optimal . Optimizers are just the method and techniques we use to update .
SGD (Stochastic Gradient Descent)
Think of the loss geometrically as a weird, crumpled landscape in very high dimensions. Each point in this landscape will then correspond to a particular choice of (and geometrically, the height represents the loss value).
If you're familiar with gradients/derivates (which you should be), the gradient is a vector pointing in the direction of steepest ascent (the direction that increases the loss the fastest). So naturally, since we want to decrease the loss, we should move in the opposite direction: giving us steepest descent.
SGD can be though of simply as “look at where the slope is steepest and take a small step downhill.”
Mathematically:
- Let be the gradient of the loss at the current parameters.
- Let be the learning rate, which just controls how big a step we take.
Then the update is:
So each iteration, we first
- Compute gradient , then
- Step in the negative gradient direction, scaled by .
This is a simple way to find the lowest point in the loss landscape, hence minimizing loss. As a quick note: in practice, we don’t compute the gradient on the full dataset every time, as it's too slow. Instead, we compute it on "mini-batches" hence the “stochastic” in SGD. But the basic idea is the same.
Problems with plain SGD
Although SGD makes sense, there are some issues:
Each update only uses the current gradient . There’s no notion of “overall direction” across time. This is the primary problem that we can visualize with the help of an example loss landscape. Suppose our loss landscape resembles a long, narrow valley. Towards the bottom, the gradient might point in alternating directions after each update as we bounce from side to side of the valley because of the step size. This leads to never converging, or converging at a point which isn't the global minimum. We can reduce the learning rate, but this brings rise to other problems which will be discussed later (one of which is just being too slow).
Also, every parameter in uses the same (although is our parameter vector, each element of it can be thought of to represent a different parameter). This can be a problem, since different parameters may have gradients with very different magnitudes, or live on different scales. We may want to update some parameters minimally, while others could need larger steps, which a single global doesn't account for.
The point is, SGD treats all parameters the same way, when each parameter may need its own effective learning rate, evolving over time based on how its gradients behave. For example, we want parameters with large & frequent gradients to take smaller steps to avoid overshooting, and parameters with small & rare gradients to take larger steps so they can still learn.
This leads naturally to AdaGrad.
AdaGrad (2011)
Core idea
AdaGrad gives each parameter its own adaptive learning rate based on how large its past gradients have been.
To implement this, for each parameter in , we keep track of the sum of its past squared gradients:
,
where:
- is the gradient of the loss with respect to parameter at step t,
- and starts at 0 and monotonically increases.
Then the update becomes:
where is a tiny constant to avoid division by zero.
How does this actually help? Looking at the equation, we can clearly see that if a parameter has seen large gradients repeatedly, its grows large. As a result, the denominator becomes big, and the effective learning rate becomes small. On the contrary, if a parameter has seen small or rare gradients, then stays small, the denominator stays small, and the effective learning rate stays relatively large.
We can see how this can be powerful with the example of a sparse setting (like NLP with large vocab sizes). Common words like “the” will get gradients all the time, so their corresponding parameters get heavily scaled down (small learning rate). Rare words get gradients only occasionally, so their parameters keep a larger learning rate and they can still move meaningfully when they finally get updated.
AdaGrad effectively normalizes each parameter’s step size by the magnitude of its historical gradients.
But I'm sure you see the issue here. is a cumulative sum, monotonically growing () So as training goes on, keeps increasing, keeps shrinking, and eventually, the steps become so tiny that learning basically stops.
AdaGrad addresses our problems early on, but over very long training runs, learning dies.
So we want to preserve the idea of per-parameter adaptive learning rates, but we don’t want them to decay to zero permanently.
Naturally, we need a way to track “recent” gradient magnitudes without letting the history grow unbounded. A straightforward solution is to replace the cumulative sum with a moving average, which is the core idea of RMSProp.
RMSProp (2012)
RMSProp modifies AdaGrad by replacing the infinitely growing sum with an exponential moving average (EMA) of squared gradients.
In the equation, instead of:
we use:
where . The update rule becomes:
The only difference here is replacing the sum with the EMA, which is essentially a running average that favors recent values. Examining the equation makes this clear; if recent gradients are large, becomes large, and if recent gradients are small, decays back down.
This fixes the issue of decaying learning rate in AdaGrad, so we have the per-parameter steps but the denominator no longer grows unbounded.
What's still missing, then? RMSProp still has a few issues. It adapts step sizes, but the issue is again, what happens at the bottom of the valley? We still have the potential issue of bouncing from side to side. We scale the step size, but don't account for the direction.
The solution to this is to accumulate a direction of travel across iterations, as we will see in Adam with the concept of momentum.
Another issue is that early in training, starts near zero. Because we initialize , the first few steps of the EMA are biased toward zero. That means the denominator can be too small at the beginning, our early effective learning rate becomes too large, and we begin with unstable jumps. This can be mitigated through a concept called bias correction.
The addition of momentum and bias correction naturally lead to the next iteration of optimizers: Adam Footnote: Who is Adam?
Adam
As mentioned, we now want to add two missing pieces: momentum to accumulate a direction of travel across steps so we don’t jitter around at the bottom of valleys, and bias correction to fix the early behavior of EMAs so our adaptive scaling isn’t unreasonable at the beginning. Note that we still want to keep the adaptive step size scaling part of RMSProp.
We'll first lay out the update equations and explain them more clearly afterward, so we can work with the concrete expressions:
Let as usual. Adam keeps track of two moving averages:
with typical values like and
These are then bias-corrected as follows: and the final update is:
As a reminder from statistics, the first moment (about zero) is just the mean, and the second moment is the mean of the squared values. So is the running mean of gradients, telling us on average which way gradients have been pointing recently, and is the running mean of squared gradients, telling us on average how large have gradients been recently. This is why they're called the first and second moments.
This is likely clear by now, but we see if gradients keep pointing roughly in the same direction, will line up with that direction and grow in magnitude. If gradients are noisy or change direction frequently, dampens that noise and gives us a more stable direction than the raw . Hence we develop momentum, a smoothed velocity vector in parameter space. And plays the same role it did in RMSProp; keeping track of the magnitude of past gradients, per parameter, so we can scale each parameter’s step size appropriately.
If the bias correction is clear, feel free to skip the following brief section. We take a quick example to show how it helps.
Initialize and .
Suppose . On step 1 we then have:
.
But if we think about what a “true” running mean of gradients would be after 1 step, it should just be , not .The EMA is biased toward zero early on because we started at 0. The same goes for . To mitigate, we'd like to divide what we currently have by 0.1.
The bias-corrected versions
are basically saying given that we started from zero and have only taken t steps, what would this EMA look like if we hadn’t started from zero? When , and we divide by 0.1, removing the bias. Then as grows, , so the correction fades away. But as we can see, in the first few steps, it’s useful for not exploding the early learning rate.
Effectively, Adam is RMSProp with momentum and bias correction. We preserve the adaptive step sizes but gain smoother motion through momentum and perform correct updates from the beginning.
We seem to have addressed all the issues, which is why, for a while, the default optimizer to use was Adam.
Adam, however, is not without its flaws. One important case which is hard to intuitively see is how it interacts with weight decay / L2 regularization.
What is weight decay?
Weight decay is quite simple and is a way to regularize the model by nudging parameters toward zero, preventing them from growing arbitrarily large and overfitting. We simply add an L2 norm.
This factors in the weights themselves to the loss function, and adds a penalty which depends on the magnitude of the weight vector, encouraging us to keep the weights small.
You may now begin to see the issue. As a quick preview to AdamW, in the standard approach (the equation above), we compute the gradient
and feed this total gradient into Adam. But this means the term (intended to be weight decay) will also get mixed into , mixed into , and then scaled by . Weight decay no longer behaves as a clean “shrink weights toward zero” operation; it becomes super entangled with the adaptive scaling. We fix this with the incredibly popular and modern optimizer AdamW.
AdamW
In an ideal world, if we had SGD with weight decay, we’d update like:
which can be rearranged as:
This is uniform shrinkage, where every parameter is pulled toward zero by the same multiplicative factor, independent of its gradient history. We actually want to preserve this behavior, even in adaptive optimizers, to ensure we're not letting any weights grow disproportionately.
However, when we add to the loss and take the gradient, we get:
.
We then plug into Adam:
where both and now depend on the sum of the original gradient and the term. The weight decay term contributes to everything. Why is this bad? Parameters with large historical gradients (large ) will have their decay term heavily scaled down, leading to less effective weight decay, and the opposite effect occurs on parameters with small historical gradients. In this case, weight decay becomes dependent on gradient statistics, distorting the geometry of regularization.
AdamW’s key contribution is to decouple the weight decay from the rest of the update, by doing the Adam gradient update without weight decay, then applying the decay step separately.
AdamW mathematically becomes:
Notice the update uses only (through and ), and doesn't use , and the decay step uses only . You’ll often see this simplified as:
Conclusion
Optimizers are a clean way to make the theoretical concepts of gradient descent more practical and workable in production. Over the course of this blog, we'll continue to use many of the optimizers introduced above, along with some more complicated variants that we'll introduce later (Muon blog coming soon!).