Adam from Scratch — Two Moving Averages and the Correction Nobody Explains
Adam is the default optimizer in almost every deep-learning codebase, and almost nobody can say what its four lines do. This article assembles it from the two problems it solves — the zigzag of plain SGD and the mismatched gradient scales between layers — then takes the bias correction apart with numbers: without it, Adam's very first step is 3.2× too large and the error peaks at 6.6×. Ends on the traps: weight decay belongs in AdamW, not Adam, and the epsilon sits outside the square root for a reason.
Key points
- Adam keeps a first moment that remembers the direction of recent gradients and a second moment that remembers their size, both as exponential moving averages.
- An EMA with decay β has an effective memory of roughly 1/(1-β) recent values, so β = 0.9 remembers about 10 steps and β = 0.999 about 1000.
- Without bias correction, the first step is 3.16× too large, peaking at 6.57× around step 12 and settling within 1% of correct only after roughly 5000 steps.
- The update is w_t = w_{t-1} - η m̂_t/(√v̂_t + ε), so a single step moves each parameter by at most about η, whatever the gradient's units.
- The defaults are β1 = 0.9, β2 = 0.999 and ε = 10^{-8}; adding λw to the gradient sends decay through the adaptive division, so use AdamW instead.
Adam: A Method for Stochastic Optimization
Primary source — what this article is built on
undefined2026-09-23
Adam: A Method for Stochastic OptimizationarXiv:1412.6980Paper page·PDFDecoupled Weight Decay Regularization (AdamW)arXiv:1711.05101Paper page·PDF
Where plain SGD runs out of road
Gradient descent is one line. You stand at parameters , you compute the gradient — the direction in which the loss climbs fastest — and you step the other way, scaled by a learning rate :
In words: look at which way is uphill, and step downhill by times the steepness. That is the whole of SGD, and if you want it built up from the ground the place to start is Gradient Descent from Scratch. Here we only need the two ways it fails, because Adam is nothing more than one fix for each.
Failure one: the zigzag. Real loss surfaces have ravines — directions in which the surface is steeply walled, crossed with directions in which it is nearly flat. On a canyon wall the gradient points mostly across the canyon, toward the near wall, and barely at all along it, toward the exit. So SGD bounces wall to wall, crawling down the gentle slope it actually needs to descend. The steep direction demands a small to avoid overshooting; the flat direction wants a large one to make any progress at all. One number cannot serve both.
Failure two: mismatched scales. In a deep network, gradients in one layer routinely arrive a hundred or a thousand times larger than gradients in another — different layer widths, different activations, different distances from the loss. A single global that is sane for the large-gradient layer is invisible to the small-gradient one, and vice versa. What you want is a per-parameter step size, set automatically.
Adam (Adaptive Moment estimation, Kingma & Ba 2014) solves failure one by remembering the direction of recent gradients, and failure two by remembering their size. Both memories are the same cheap device: an exponential moving average.
The device: an exponential moving average
Every piece of Adam is built from one recurrence. Given a stream of numbers and a decay rate between 0 and 1, the exponential moving average (EMA) is
Read it as a sentence: keep of what you already believed, and mix in of what you just saw. Unrolling it shows what it really is — a weighted average of the whole history, with weights that shrink geometrically into the past:
The useful intuition is that an EMA with decay has an effective memory of roughly recent values. So remembers about the last 10 steps, and about the last 1000. That single fact will explain both of Adam's default decay rates and, later, why one of them needs a correction that lasts for hundreds of steps.
Note the initialisation: . We start with no history, so we start from zero. That choice is the entire origin of the bias problem two sections from now — hold on to it.
First moment: remembering the direction
Apply the EMA to the gradients themselves. This is the first moment estimate, — an estimate of the mean gradient:
In words: the direction we trust is mostly the direction we trusted a moment ago, nudged 10% toward the gradient we just saw. Piece by piece: is what we believed the typical gradient direction was before this step; is the gradient we just measured on this mini-batch; decides how much of the old belief survives. Stepping along instead of along is momentum, and it fixes the zigzag directly. The side-to-side components of the ravine bounce keep reversing sign, so in the running average they cancel. The down-the-canyon component has the same sign every step, so it accumulates. The optimizer behaves like a heavy ball: it stops rattling between the walls and keeps its downhill roll.
Momentum also smooths the mini-batch noise. Each is a noisy estimate of the true gradient, computed from a handful of examples; averaging ten of them gives a steadier direction than trusting any one.
Second moment: remembering the size
Now apply the same EMA to the squared gradients. This is the second moment estimate, :
In words: keep a slow-moving record of how big this parameter's gradients usually are, ignoring which way they point. The squaring is elementwise — each parameter keeps its own , tracking how large that parameter's gradients have recently been, regardless of sign. So is a running estimate of the typical magnitude of that coordinate's gradient (its root-mean-square), which is where the name RMSProp comes from: this line is exactly RMSProp, the method Adam inherits it from.
The plan is to divide the step by . Think about what that does. A coordinate whose gradients are consistently huge gets a large and therefore a small step; a coordinate whose gradients are consistently tiny gets a small and a large step. The division cancels the scale of the gradient and leaves only its sign and consistency — which is precisely the per-parameter learning rate that failure two was asking for. Layers with mismatched gradient magnitudes end up taking comparable steps without anyone tuning them separately.
Bias correction: the first steps are lying to you
We initialised both averages at zero. That was an honest choice — we had no history — but it makes the early estimates wrong in a known direction, and the fix is the one part of Adam that gets skipped in every casual explanation.
Watch the very first step. Suppose the gradient is . Then
We measured a gradient of 1 and our estimate of the typical gradient is 0.1. It is not that the estimate is noisy; it is that it has been dragged 90% of the way toward the fictitious zero we seeded it with. The same thing happens to the second moment, only far worse, because is closer to 1: .
The size of the lie is exactly computable. Take the unrolled EMA with every equal to the same value . The weights are , a geometric series that sums to rather than to 1. So
In words: after steps, each average holds only a fraction of the true value; the rest is still the zero we started from. The estimate is shrunk by a factor we know. Divide it back out, and the bias is gone. Those two divisions are the bias-corrected moments:
In words: scale each average back up by exactly the amount the zero start shrank it. Check the first step again: , which is the gradient we actually measured. And . The correction is exact at and fades as , so it matters early and disappears later — which is exactly the shape of the problem.
Now, does it matter in practice? It is tempting to argue that both moments are shrunk, so the ratio cancels the error out. It does not, because the two are shrunk by different amounts: the numerator carries and the denominator carries . The ratio of the uncorrected step to the correct one is
In words: the numerator recovers from its zero start within about 10 steps, the denominator takes about 1000, so for a long stretch you are dividing a nearly-honest number by a still-shrunken one. Feed a constant gradient into Adam with the standard , and the corrected update takes a step of exactly from step 1 onward — the right answer. The uncorrected update takes 3.16× that on step 1, rises to a peak of 6.57× around step 12, is still 3.2× too large at step 100 and 1.4× too large at step 693, and only settles within 1% of correct after roughly 5000 steps. Skipping bias correction does not cost you a couple of shaky steps; it hands you thousands of steps of inflated learning rate, at exactly the moment when the parameters are furthest from any minimum. That is why so much early Adam folklore insisted on a warmup — a good part of what warmup was patching is this.
The long tail comes from : the factor only reaches 0.5 at . The second moment has a memory of a thousand steps, so its start-up transient lasts a thousand steps too.
The update rule, assembled
Four lines per step, in order:
Read the last line as one sentence. is the direction, smoothed over recent history. is the typical size of this coordinate's gradient. Dividing the first by the second produces a signal-to-noise ratio: large when recent gradients agree in sign (so the mean is nearly as big as the RMS, and the ratio approaches 1), small when they thrash and cancel (mean near zero, RMS still large, ratio near 0). Adam steps confidently where the gradient is consistent and timidly where it is contradictory, one coordinate at a time. And is a tiny constant that stops the division from exploding when a coordinate's gradients are all ~0.
The bound is worth noticing: because the ratio is at most about 1 in the well-behaved case, a single Adam step moves each parameter by at most about , whatever the gradient's units. That is why an Adam learning rate transfers between models far better than an SGD one — is a step size in parameter space, not a scale factor on an unknown gradient.
The defaults, and what each one is for
The paper's defaults are , , , and they are the values in your framework right now. Each has a reading.
gives the direction a memory of about steps. Long enough to cancel the ravine wobble and average out mini-batch noise, short enough to turn when the surface genuinely turns. Raising it to 0.95 or 0.98 (common for large language models, where batches are huge and each gradient is already low-noise) buys a smoother trajectory at the cost of slower response.
gives the scale a memory of about 1000 steps. Scale should be estimated over a much longer window than direction, because it is a statistic about the distribution of gradients rather than a heading, and a jumpy denominator would make the step size jitter. The price is the thousand-step start-up transient from the previous section. Practitioners drop it to 0.95–0.98 when training is unstable and gradient magnitudes are shifting fast.
is numerical insurance for the division. It matters more than its size suggests, because it also silently caps the step: when a coordinate's falls well below , the ratio becomes -limited rather than scale-free, and that coordinate stops getting its adaptive boost. Very small makes flat coordinates take enormous steps; larger values (, and or higher in some mixed-precision and RL setups) make Adam behave more like SGD with momentum. It is not a dummy constant.
And is still the dial that matters most. is the classic default; fine-tuning a transformer usually wants to . Adam adapts the relative step between parameters; it does not choose the absolute size for you.
Seeing it in code
The whole optimizer is a dozen lines. Here it is minimizing the quadratic bowl , whose gradient is , starting from :
import numpy as np
def grad(w): return 2 * w # dL/dw for L = w^2
w = 5.0
m = v = 0.0 # both moments start at zero
beta1, beta2, eta, eps = 0.9, 0.999, 0.1, 1e-8
for t in range(1, 201): # t starts at 1 — the correction needs it
g = grad(w)
m = beta1 * m + (1 - beta1) * g # 1st moment: direction
v = beta2 * v + (1 - beta2) * g * g # 2nd moment: size
m_hat = m / (1 - beta1 ** t) # bias correction
v_hat = v / (1 - beta2 ** t)
w -= eta * m_hat / (np.sqrt(v_hat) + eps)
if t <= 5 or t == 200:
print(f"step {t:3d}: w = {w:+.4f}")
Running it gives w = +4.9000, +4.8001, +4.7002, +4.6005, +4.5010 and then w = -0.0001 at step 200. Look at the early steps: each one moves by almost exactly , regardless of the fact that the gradient there is 10. That is the scale-free property, visible in five numbers.
Now delete the two correction lines and use m and v directly. The first step becomes w = +4.6838 — a move of 0.3162, which is , the exact inflation factor predicted in the bias-correction section. The theory and the print statements agree to four digits.
Pitfalls
Weight decay in Adam is not weight decay — use AdamW. The textbook way to add L2 regularization is to append to the gradient. Do that inside Adam and the penalty goes through the division along with everything else, so parameters with large gradients get less decay than parameters with small ones — the strength of your regularizer becomes an accident of gradient magnitude. Loshchilov & Hutter's fix (AdamW, 2017) is to decouple it: apply the adaptive step, then subtract separately, outside the division. This is not a micro-optimization; the paper reports that it substantially improves Adam's generalization, letting it compete with SGD with momentum on image classification, and it is the reason essentially every modern language model is trained with AdamW. Note also that PyTorch's Adam(weight_decay=...) is the coupled, worse version — reach for AdamW explicitly.
Adam converges faster but can generalize worse. On many vision tasks, well-tuned SGD with momentum still reaches a better test accuracy than Adam, even though Adam gets the training loss down sooner. The usual explanation is that the adaptive denominator suppresses exactly the gradient noise that pushes SGD out of sharp minima and into flatter, better-generalizing ones. Practical reading: for a transformer or anything with wildly uneven gradient scales, Adam/AdamW is the default and SGD often does not train at all; for a convnet on a fixed benchmark where you can afford to tune, SGD+momentum is still worth a run.
The epsilon goes inside, next to the square root — not under it. The correct denominator is . Writing is a different algorithm: the second form adds in units of squared gradient and so kicks in at a completely different threshold ( rather than ). Both forms appear in real implementations, so if you port an value between frameworks, check which convention you are landing in before trusting it.
Adam costs memory. Two extra float buffers per parameter, each the size of the model. For a 7B-parameter model in fp32 that is roughly 56 GB of optimizer state on top of the weights and gradients, which is why 8-bit optimizers, Adafactor, and sharded states exist. SGD with momentum needs one such buffer; plain SGD needs none.
must be the global step, and it must start at 1. At the correction divides by . If you reset when resuming from a checkpoint, you reintroduce the whole start-up transient — a silent 3–6× learning-rate spike right after every restart.
In practice
Who touches this, and when. An ML engineer fine-tuning a pretrained model, writing the training config. A researcher training a new architecture from scratch, chasing down why the loss spikes or goes NaN early on. In both cases, knowing which of Adam's four lines is doing the work lets you pick settings for a reason instead of by incantation.
The names you actually type. In PyTorch it is torch.optim.AdamW(params, lr=..., betas=(0.9, 0.999), eps=1e-8, weight_decay=...). Hugging Face's TrainingArguments exposes the same knobs as learning_rate, adam_beta1, adam_beta2, adam_epsilon and weight_decay. When memory is tight, the 8-bit variant from bitsandbytes (AdamW8bit) is the usual candidate. The optimizer's state lives in optimizer.state_dict(), where each parameter has a step, an exp_avg () and an exp_avg_sq (). The symbols in this article are literally the key names, so while debugging you can open the state and look for, say, a layer whose is essentially zero.
Choosing what gets weight decay. In practice you rarely pass params as one flat list. The standard move is to split it into parameter groups: one-dimensional parameters such as biases and LayerNorm weights go into a group with weight_decay=0.0, and decay is applied only to the weight matrices. The usual reasoning is that these set scales and offsets, so pulling them toward zero does little against overfitting and mostly just shrinks what the layer can express.
Traps that cause real incidents.
- Checkpointing only the weights. On resume,
exp_avg,exp_avg_sqandsteprestart from zero, and you go through the whole bias-correction start-up again. Saveoptimizer.state_dict()(and the scheduler's state) alongside the model, and load it back when you resume. - Using in fp16. The smallest positive fp16 number (a subnormal) is about , so rounds to zero. In an implementation that keeps optimizer state in fp16, vanishes and the division can blow up on near-zero gradients. That is why mixed-precision training normally keeps the optimizer state in fp32 — and why you should check when you write your own or use a low-precision optimizer.
- Fine-tuning with the default
lr=1e-3. As the defaults section showed, Adam only equalizes the relative step. To avoid wrecking pretrained weights, lowering is your job.
Questions that come up in interviews and code review.
- "What's the difference between Adam and AdamW?" → Whether the decay term passes through the division. A complete answer adds that when it does, parameters with large gradients get weaker decay.
- "What happens if you drop bias correction?" → The early effective learning rate is inflated by ; numerator and denominator recover on different timescales, so they do not cancel.
- "The loss suddenly spiked mid-run. What do you suspect on the optimizer side?" → Optimizer state not restored on resume; a so small that no longer protects the division; or a so large that the denominator cannot keep up with a sudden change in gradient size.
Summary
- Adam = momentum (remember the gradient's direction) + RMSProp (remember its size), each an exponential moving average, plus a bias correction
- cancels the ravine zigzag; gives every parameter its own effective learning rate
- Both averages start at 0 and are therefore shrunk by ; dividing that out gives . Skipping it inflates the first step by 3.16×, peaks at 6.57× near step 12, and takes ~5000 steps to fade
- The update is a signal-to-noise ratio: bold where gradients agree, cautious where they contradict, and bounded by roughly per step
- Defaults , , correspond to memories of ~10 and ~1000 steps; is a real hyperparameter, not decoration
- Use AdamW for weight decay, budget two extra buffers per parameter, and don't assume Adam generalizes as well as tuned SGD
For where the one-line update comes from, see Gradient Descent from Scratch; for the wider family of optimizers Adam sits in, Beyond SGD; and for the schedules that ride on top of , Learning-Rate Schedules.
Exercises
- With , a gradient stream that is exactly for every step. Compute , , and the corrected , , . What does the corrected sequence tell you?
- Two parameters receive constant gradients: every step and every step. After the transient has passed, how far does each move per Adam step with ? How far would each move under plain SGD with the same ?
- A colleague reports that removing bias correction "made no difference because it cancels in the ratio." Give the exact expression for the inflation factor, evaluate it at and with the default betas, and say what he would have to change about for his claim to be nearly true.
- You add
weight_decay=0.01totorch.optim.Adam. Which layer ends up more strongly decayed — an embedding layer whose gradients are tiny, or an output head whose gradients are large? Work it out from the update rule, then name the one-word fix.
Answers
- ; ; . The corrections divide by : , , . The corrected estimate equals the true mean gradient exactly at every step, while the raw needs dozens of steps to creep up to 2. Bias correction is what makes the estimate unbiased from step 1.
- With a constant gradient, and , so the ratio is 1 (the is negligible against both 100 and 0.01) and both parameters move by per step. Under plain SGD the moves are and — a factor of apart, so one parameter is exploding while the other is frozen. This is the per-parameter learning rate in one comparison.
- The factor is . At : . At : — still more than three times too large after a hundred steps. His claim needs , which at means . With that value the factor is exactly 1.000 at and never exceeds 2.13 (peak near ) — much closer to harmless. The errors fail to cancel at precisely because the two transients then live on completely different timescales: the direction's settles in ~10 steps, the scale's in ~1000.
Adam(weight_decay=...)adds to the gradient before the adaptive step, so the decay travels through the division like everything else. The decay contribution to one step is roughly . The output head has large gradients, so a large denominator, so its decay is damped; the embedding has tiny gradients, so a small denominator, so its decay is amplified — in the extreme, where dominates the gradient entirely, and the decay saturates at about per step regardless of . So the low-gradient embedding is the more strongly decayed one, and the effective regularization strength is no longer the you set but an artifact of each layer's gradient scale. The fix is one word: AdamW, which subtracts outside the division so every parameter decays at the same rate.
Comments
Sign in to comment