Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Two model parameters are each one unit from their best value. The loss gradient asks you to change one 100 times more than the other. How big should a training step be?
The linear algebra lesson showed how a matrix can stretch different directions by different amounts. The same imbalance appears in optimization: a learning rate that barely moves one parameter can send another past its target.
A gradient gives the loss's local slope in each parameter direction. An optimizer uses those slopes to choose an update. Moving against the gradient lowers a smooth loss for a sufficiently small step, but a large step or an update based on past gradients needn't lower it.[1]

We'll keep both coordinates visible as we add gradient history, adaptive scaling, weight decay, and a schedule. The examples use NumPy on a CPU; the final training loop uses PyTorch. No model download or GPU is needed.
A 100-to-1 valley
Let a and b be the two parameter errors. Their target is [0, 0].
Use a quadratic loss so every gradient and update is easy to check:
The coefficient 100 means the slope changes 100 times faster along a than along b. This is curvature, not a claim that one feature is inherently more important. In matrix form:
has condition number 100. A single step size is therefore fighting the same uneven stretch that made inverse-like solves fragile in the last chapter.
Differentiate to get
At [1, 1], the loss is 50.5 and the gradient is [100, 1]. Equal coordinate error hasn't produced equal correction requests: a asks for a step 100 times larger. What should happen if one learning rate serves both?
Run this small calculation and inspect both coordinates, not only the loss.
1import numpy as np
2
3def loss(w: np.ndarray) -> float:
4 return float(0.5 * (100.0 * w[0] ** 2 + w[1] ** 2))
5
6def gradient(w: np.ndarray) -> np.ndarray:
7 return np.array([100.0 * w[0], w[1]])
8
9w = np.array([1.0, 1.0])
10g = gradient(w)
11next_w = w - 0.018 * g
12
13print("start loss", loss(w))
14print("gradient", g.tolist())
15print("one step", next_w.round(3).tolist())
16print("next loss", round(loss(next_w), 3))1start loss 50.5
2gradient [100.0, 1.0]
3one step [-0.8, 0.982]
4next loss 32.482The loss falls after one step, but the coordinates tell a less comfortable story. a crosses from 1.0 to -0.8, while b only reaches 0.982.
That crossing is the zigzag: the steep error bounces across zero while the shallow error barely walks. A lower rate would calm the wall, but it would also slow the floor.
SGD gives every coordinate one learning rate
Before naming the update stochastic gradient descent (SGD), separate two choices: where the gradient comes from and how the optimizer applies it.
| Method | Gradient source | What the optimizer sees |
|---|---|---|
| full-batch gradient descent | every training example | an exact gradient for the current dataset |
| stochastic gradient descent | one example, or a small randomly selected batch | a noisy estimate of the full gradient |
| minibatch training | a batch containing several examples | the usual practical form of SGD, with noise that depends on batch size |
This walkthrough uses a deterministic two-coordinate gradient so the loss geometry stays visible. It stands in for a minibatch gradient without adding sampling noise. Real training adds that noise, so log batch size and gradient statistics when comparing optimizers.
Once the gradient source is fixed, SGD applies a single rule:
Here, is the parameter vector after step and is the minibatch gradient. (eta) is the learning rate, the step-size multiplier.
Try three rates for eight steps on the same valley. Predict which rate keeps a stable and which moves b farther.
1import numpy as np
2
3def loss(w: np.ndarray) -> float:
4 return float(0.5 * (100.0 * w[0] ** 2 + w[1] ** 2))
5
6def gradient(w: np.ndarray) -> np.ndarray:
7 return np.array([100.0 * w[0], w[1]])
8
9def run_sgd(lr: float, steps: int = 8) -> tuple[np.ndarray, float]:
10 w = np.array([1.0, 1.0])
11 for _ in range(steps):
12 w -= lr * gradient(w)
13 return w, loss(w)
14
15for lr in (0.005, 0.018, 0.021):
16 w, final_loss = run_sgd(lr)
17 print(f"lr={lr:.3f} w={w.round(3).tolist()} loss={final_loss:.3f}")1lr=0.005 w=[0.004, 0.961] loss=0.462
2lr=0.018 w=[0.168, 0.865] loss=1.781
3lr=0.021 w=[2.144, 0.844] loss=230.105The small rate is calm, but b is still 0.961 after eight steps. It also reports the lowest loss of these three runs, so a bigger rate isn't automatically faster learning.
The middle rate drives down a while b still crawls. To see the stability boundary, substitute the gradient into the update: a_next = (1 - 100 * lr) * a. Its magnitude shrinks only when abs(1 - 100 * lr) < 1, or 0 < lr < 0.02. At exactly 0.02, a flips sign without shrinking. At 0.021, the factor is -1.1, so the oscillation grows.
Why can't increasing SGD's learning rate solve the slow progress on b here?
Answer
The same rate also multiplies the much steeper gradient on a. Once the rate exceeds 0.02, the a update flips sign and grows instead of shrinking. A rate safe for a leaves b comparatively slow.
Momentum remembers directions that agree
SGD reacts only to the current gradient. When the steep coordinate flips sign, it forgets that the previous step was already heading back across the wall.
Momentum keeps a signed moving average of recent gradients. Use the exponential-moving-average convention here because it matches Adam's first buffer. This is a normalized teaching form of Polyak's heavy-ball idea, described in his 1964 paper on accelerating iterative methods:
The coefficient controls memory. With , the new gradient contributes 0.1 and the previous direction contributes 0.9.
PyTorch's torch.optim.SGD uses an unnormalized buffer: with default dampening, it starts at the first gradient and then computes buffer = beta * buffer + gradient. Our m is (1 - beta) times that buffer. To reproduce our updates in that convention, multiply our learning rate by (1 - beta). Check initialization and dampening too before translating another implementation. PyTorch documents these choices.
Feed momentum a prescribed four-gradient history: large alternating values for a, small positive values for b. These are inputs chosen to expose the buffers, not gradients produced by a momentum run. We'll reuse them for RMSProp and Adam. Which signs cancel, and which reinforce one another?
1import numpy as np
2
3beta = 0.9
4gradients = [
5 np.array([100.0, 1.00]),
6 np.array([-80.0, 0.98]),
7 np.array([70.0, 0.96]),
8 np.array([-60.0, 0.94]),
9]
10m = np.zeros(2)
11
12for step, g in enumerate(gradients, start=1):
13 m = beta * m + (1.0 - beta) * g
14 print(step, "gradient", g.round(2).tolist(), "memory", m.round(3).tolist())11 gradient [100.0, 1.0] memory [10.0, 0.1]
22 gradient [-80.0, 0.98] memory [1.0, 0.188]
33 gradient [70.0, 0.96] memory [7.9, 0.265]
44 gradient [-60.0, 0.94] memory [1.11, 0.333]The first buffer stays positive throughout this trace, even when the current gradient is negative. Opposing gradients partially cancel: on step two, 0.9 * 10 + 0.1 * (-80) = 1. The second buffer grows from 0.1 to about 0.333 because every gradient reinforces the same sign. These signed averages are what momentum subtracts from the parameters after multiplying by the learning rate.
Momentum still needs a suitable learning rate for each surface. It changes the path: useful repeated direction builds up, while alternating motion is damped. Now compare measured paths on the same loss instead of assuming that adding a buffer guarantees a win.
1import numpy as np
2
3def loss(w: np.ndarray) -> float:
4 return float(0.5 * (100.0 * w[0] ** 2 + w[1] ** 2))
5
6def gradient(w: np.ndarray) -> np.ndarray:
7 return np.array([100.0 * w[0], w[1]])
8
9def sgd(steps: int, lr: float) -> np.ndarray:
10 w = np.array([1.0, 1.0])
11 for _ in range(steps):
12 w -= lr * gradient(w)
13 return w
14
15def momentum(steps: int, lr: float, beta: float = 0.9) -> np.ndarray:
16 w = np.array([1.0, 1.0])
17 m = np.zeros_like(w)
18 for _ in range(steps):
19 m = beta * m + (1.0 - beta) * gradient(w)
20 w -= lr * m
21 return w
22
23for name, w in [
24 ("sgd", sgd(40, lr=0.018)),
25 ("momentum", momentum(40, lr=0.018)),
26]:
27 print(name, "w", w.round(4).tolist(), "loss", round(loss(w), 4))1sgd w [0.0001, 0.4836] loss 0.1169
2momentum w [0.0307, 0.5324] loss 0.1889At this unchanged numeric learning rate, SGD ends lower than our EMA-style momentum run. That's a valid result, not a failed lesson.
Momentum changes the update scale and usually needs its own learning-rate and memory-coefficient sweep. The benefit to look for is a better-tuned path, not a guaranteed win from adding a buffer.
RMSProp rescales by recent gradient magnitude
Momentum smooths signed direction, but it still sends every coordinate through one global learning rate.
RMSProp keeps a second, unsigned history: an exponential moving average of squared gradients. The method was documented by Tieleman and Hinton in their 2012 RMSProp lecture note.
The s buffer forgets old squared magnitudes. A large recent gradient makes that coordinate's denominator large, so its update is scaled down.
Unlike momentum's signed buffer, squaring keeps s from canceling when signs alternate. Each RMSProp coordinate keeps the current gradient's sign, but rescaling coordinates differently changes the direction of the whole update vector. This basic form has no Adam-style bias correction.
PyTorch calls rho alpha; its documented RMSprop defaults are lr=0.01, alpha=0.99, and eps=1e-8 (official docs).
PyTorch adds after the square root; some other libraries add it inside. Copying between them can silently change the update.
Prediction check: This fixture uses
rho=0.9so four steps make the state change easy to inspect. Before running it, predict whether the first update magnitudes will still differ by 100:1.
Use the same deliberately uneven gradient history to test that prediction:
1import numpy as np
2
3rho, lr, eps = 0.9, 0.01, 1e-8
4gradients = [
5 np.array([100.0, 1.00]),
6 np.array([-80.0, 0.98]),
7 np.array([70.0, 0.96]),
8 np.array([-60.0, 0.94]),
9]
10s = np.zeros(2)
11
12for step, g in enumerate(gradients, start=1):
13 s = rho * s + (1.0 - rho) * g**2
14 update = lr * g / (np.sqrt(s) + eps)
15 print(step, "rms", np.sqrt(s).round(3).tolist(), "step", update.round(5).tolist())11 rms [31.623, 0.316] step [0.03162, 0.03162]
22 rms [39.243, 0.431] step [-0.02039, 0.02272]
33 rms [43.313, 0.51] step [0.01616, 0.01884]
44 rms [45.259, 0.567] step [-0.01326, 0.01657]The first row gives both coordinates nearly the same update magnitude, despite the 100:1 raw gradients. With rho=0.9, the first denominator is about sqrt(0.1) * abs(g), so the update magnitude is lr / sqrt(0.1), about 0.03162. Later, the first coordinate's denominator stays large, but its update follows the latest gradient sign. Here and below, update means the vector we subtract from the parameters.
That is the handoff to Adam: add momentum's signed m buffer to this RMSProp-like v buffer, then correct both buffers for their zero start.
| Method | State per coordinate | Update scale | Keeps signed direction? |
|---|---|---|---|
| SGD | none | one global learning rate | current gradient only |
| momentum | signed m | one global learning rate | smoothed history |
| RMSProp | squared s | 1 / (sqrt(s) + eps) | current gradient only |
| Adam | signed m and squared v | 1 / (sqrt(v_hat) + eps) after bias correction | smoothed, bias-corrected history |
Adam stores direction and squared-gradient scale
The Adam optimizer (Adaptive Moment Estimation) keeps the signed average for direction and adds a second moving average for scale: the elementwise square of gradients. Its original formulation is:[2]
Read the four lines as two decisions. m chooses a direction from recent signs; v measures how large recent gradients have been. Bias correction turns those startup-shrunken buffers into the hatted estimates used by the update.
The symbols have precise jobs:
| Symbol | Meaning | What it remembers |
|---|---|---|
| first-moment estimate | recent signed direction | |
| uncentered second-moment estimate | recent squared gradient magnitude | |
| bias-corrected estimates | startup-adjusted state | |
| small denominator guard | avoids division by a near-zero scale |
Kingma and Ba's usual defaults are , , and , with added outside the square root in the denominator.[2]
PyTorch's documented AdamW constructor starts with lr=1e-3, the same betas and eps, and weight_decay=1e-2. They're starting points, not guarantees for a new model or dataset. PyTorch AdamW docs
v is often casually called variance. That wording is misleading here: Adam averages g ** 2; it doesn't subtract the mean gradient to calculate statistical variance.
The first step is a useful sanity check. With bias correction, a nonzero gradient of 100 and one of 1 both produce an update close to one learning rate.
Prediction check: Before running the code, predict what will be equal and what will still differ in the two moment buffers.
1import numpy as np
2
3g = np.array([100.0, 1.0])
4beta1, beta2, lr, eps = 0.9, 0.999, 0.01, 1e-8
5m = (1.0 - beta1) * g
6v = (1.0 - beta2) * g**2
7m_hat = m / (1.0 - beta1)
8v_hat = v / (1.0 - beta2)
9update = lr * m_hat / (np.sqrt(v_hat) + eps)
10
11print("m_hat", m_hat.tolist())
12print("v_hat", v_hat.tolist())
13print("update", update.round(6).tolist())1m_hat [100.0, 1.0]
2v_hat [10000.0, 1.0]
3update [0.01, 0.01]
a's numerator, while squared magnitudes remain large. The consistently positive b history keeps its step near the learning rate. These are calculations for this fixture, not a guarantee about a training trajectory.Now replay the same prescribed history used for momentum and RMSProp. Look especially at steps two and four, when a's raw gradient is negative:
1import numpy as np
2
3gradients = [
4 np.array([100.0, 1.00]),
5 np.array([-80.0, 0.98]),
6 np.array([70.0, 0.96]),
7 np.array([-60.0, 0.94]),
8]
9beta1, beta2, lr, eps = 0.9, 0.999, 0.01, 1e-8
10m = np.zeros(2)
11v = np.zeros(2)
12
13for t, g in enumerate(gradients, start=1):
14 m = beta1 * m + (1.0 - beta1) * g
15 v = beta2 * v + (1.0 - beta2) * g**2
16 m_hat = m / (1.0 - beta1**t)
17 v_hat = v / (1.0 - beta2**t)
18 update = lr * m_hat / (np.sqrt(v_hat) + eps)
19 print(t, "m_hat", m_hat.round(3).tolist(), "step", update.round(5).tolist())11 m_hat [100.0, 1.0] step [0.01, 0.01]
22 m_hat [5.263, 0.989] step [0.00058, 0.00999]
33 m_hat [29.151, 0.979] step [0.00346, 0.00998]
44 m_hat [3.228, 0.967] step [0.00041, 0.00997]Unlike RMSProp, Adam still subtracts a positive value from a on steps two and four. Its signed average hasn't turned negative yet. Momentum can keep a parameter moving against the current gradient, so smoothing doesn't guarantee descent on every step. The consistently positive b history keeps its update near 0.01.
Adam isn't finding curvature or proving which feature matters. It transforms recent gradient history into coordinate-wise steps. That distinction matters when you diagnose a run: adaptive scaling isn't a geometry oracle.
Bias correction repairs zero-initialized state
Both moving averages begin at zero, so early values are pulled toward that initialization.
With Adam's usual coefficients, the first raw first-moment buffer is 0.1 * g, while the first raw second-moment buffer is 0.001 * g**2. Dividing those raw values would make the startup update too large. Bias correction removes that startup pull.
Compare the first update with and without correction for one scalar parameter:
1import math
2
3gradient = 4.0
4beta1, beta2, lr, eps = 0.9, 0.999, 0.01, 1e-8
5m = (1.0 - beta1) * gradient
6v = (1.0 - beta2) * gradient**2
7
8uncorrected_step = lr * m / (math.sqrt(v) + eps)
9m_hat = m / (1.0 - beta1)
10v_hat = v / (1.0 - beta2)
11corrected_step = lr * m_hat / (math.sqrt(v_hat) + eps)
12
13print("uncorrected step", round(uncorrected_step, 5))
14print("corrected step", round(corrected_step, 5))1uncorrected step 0.03162
2corrected step 0.01Why divide by 1 - beta**t? If every gradient were the same value g, the first two averages would be (1 - beta) * g and (1 - beta**2) * g. After t steps, the weights on observed gradients sum to 1 - beta**t; division restores that sum to one. It removes the zero-start effect, not the uncertainty or lag in a changing gradient history.
Why isn't v_t the statistical variance of a coordinate's gradient?
Answer
Adam updates v_t from squared gradients, g_t ** 2, without subtracting a mean. It's an uncentered second-moment estimate: a running measure of squared magnitude.
AdamW separates data gradients from weight shrinkage
Regularization often nudges parameter magnitudes downward. Define the L2 penalty here as , whose gradient is .
With ordinary SGD, that penalty can produce the same shrinkage behavior as weight decay after adjusting coefficients. With adaptive optimizers, those operations aren't equivalent: putting the penalty inside Adam's gradient also sends it through coordinate-wise scaling.[3]
AdamW applies a decoupled decay directly to the prior parameter value:
is the weight-decay coefficient. The data-gradient update and shrinkage term are visible separately.
The difference appears even on the first step with a zero data gradient and zero moment buffers. Compare a parameter of 10.0 under the two rules:
1theta = 10.0
2lr = 0.1
3weight_decay = 0.01
4eps = 1e-8
5
6adamw_theta = theta - lr * weight_decay * theta
7
8# Gradient of (weight_decay / 2) * theta**2.
9coupled_gradient = weight_decay * theta
10coupled_adam_step = lr * coupled_gradient / (abs(coupled_gradient) + eps)
11coupled_theta = theta - coupled_adam_step
12
13print("AdamW with zero data gradient", round(adamw_theta, 4))
14print("Adam plus coupled L2 first step", round(coupled_theta, 4))1AdamW with zero data gradient 9.99
2Adam plus coupled L2 first step 9.9The penalty has entered both Adam buffers in the coupled version. In AdamW, it enters neither: the parameter shrinks by the separate factor 1 - lr * weight_decay. Decoupled doesn't mean independent of the schedule, because lr still multiplies the decay. In PyTorch, a zero gradient tensor also differs from grad=None: the optimizer skips parameters with no gradient, including their decay. AdamW API
In PyTorch 2.14, torch.optim.Adam(..., decoupled_weight_decay=True) also selects AdamW behavior. The default is False, so the weight_decay argument alone still selects coupled L2. Inspect the actual constructor rather than inferring the rule from the optimizer's name. Adam API
Schedules change pace across the run
An optimizer decides how to interpret gradients at one step. A learning-rate schedule changes the global multiplier across many steps.
| Phase | What you're controlling | A measurable reason to adjust pace |
|---|---|---|
| startup | avoid a large initial global step | loss or gradient norm spikes immediately |
| middle | make progress while the model learns | validation loss is still improving |
| late | reduce movement around a good region | progress becomes noisy or stalls |
The original Transformer training recipe used Adam with , , and , plus a schedule that increased the learning rate linearly for 4000 warmup steps and then decreased it in proportion to the inverse square root of the step number.[4] Those betas and aren't Kingma and Ba's defaults.
Treat the whole recipe as evidence that one successful run existed, not as a universal setting. Cosine annealing and warm restarts were studied separately by Loshchilov and Hutter; a schedule still needs measurement on your task.
Warmup followed by cosine decay is a shape you can inspect by hand. In this zero-based convention, step counts optimizer updates, not batches or epochs. Update zero uses rate zero, update warmup_steps reaches the peak, and update total_steps - 1 reaches the final rate. Other recipes start at a small nonzero rate, so check the indexing when porting a schedule.
1import math
2
3def warmup_cosine(step: int, total_steps: int, warmup_steps: int, peak_lr: float, final_lr: float) -> float:
4 if not 0 <= warmup_steps < total_steps - 1:
5 raise ValueError("leave at least two schedule points at or after the peak")
6 if not 0 <= step < total_steps:
7 raise ValueError("step must be within the run")
8 if not 0 <= final_lr <= peak_lr or not math.isfinite(peak_lr):
9 raise ValueError("rates must be finite and satisfy 0 <= final_lr <= peak_lr")
10 if step < warmup_steps:
11 return peak_lr * step / warmup_steps
12 progress = (step - warmup_steps) / (total_steps - warmup_steps - 1)
13 cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
14 return final_lr + (peak_lr - final_lr) * cosine
15
16values = [
17 warmup_cosine(step, total_steps=10, warmup_steps=2, peak_lr=3e-4, final_lr=3e-5)
18 for step in range(10)
19]
20print([f"{lr:.6f}" for lr in values])1['0.000000', '0.000150', '0.000300', '0.000287', '0.000249', '0.000195', '0.000135', '0.000081', '0.000043', '0.000030']Here the peak is at index 2 and the final rate at index 9. With gradient accumulation, several batches can contribute to one optimizer update; don't advance this schedule on each of those batches. Log the actual rate used alongside the loss so an off-by-one error is visible.
Gradient clipping bounds the input to the optimizer
A malformed or unusually difficult batch can create an unusually large gradient. Global-norm gradient clipping treats every parameter gradient as one long vector and measures its L2 norm.
When that norm exceeds a limit, clipping rescales all gradients together:
Here, is the clipping threshold. Every coordinate gets the same scaling factor, so clipping preserves the gradient's direction before it enters the optimizer. Predict which of the two vectors below will change.
1import numpy as np
2
3def clip_global_norm(gradient: np.ndarray, max_norm: float) -> tuple[np.ndarray, float]:
4 if not np.isfinite(max_norm) or max_norm <= 0:
5 raise ValueError("max_norm must be finite and positive")
6 norm = float(np.linalg.norm(gradient))
7 if not np.isfinite(norm):
8 raise ValueError("gradient norm must be finite")
9 if norm == 0.0:
10 return gradient.copy(), norm
11 scale = min(1.0, max_norm / norm)
12 return gradient * scale, norm
13
14ordinary = np.array([3.0, 4.0])
15outlier = np.array([300.0, 400.0])
16
17for name, g in [("ordinary", ordinary), ("outlier", outlier)]:
18 clipped, original_norm = clip_global_norm(g, max_norm=5.0)
19 print(name, "before", original_norm, "after", round(float(np.linalg.norm(clipped)), 3), "value", clipped.round(2).tolist())1ordinary before 5.0 after 5.0 value [3.0, 4.0]
2outlier before 500.0 after 5.0 value [3.0, 4.0]Clipping bounds the optimizer's gradient input, not the full AdamW update by that same threshold. Adam state, learning rate, and weight decay also affect how far the parameters move. For example, at Adam's first step, both [300, 400] and its clipped version [3, 4] yield updates close to [lr, lr] when epsilon is negligible. Clipping still changes the history stored for later steps.
If clipping fires on nearly every batch, investigate data, loss scaling, model stability, or the threshold rather than hiding the signal.
Clip before optimizer.step(). step() uses its input gradient to update AdamW's m and v buffers as well as the parameters, so clipping first keeps one outlier from entering all three paths.
Build it: optimize the valley with AdamW
Use the equations to implement AdamW and run it beside SGD on the same loss surface. Log final loss and both coordinate errors.
The peak rates differ because adaptive normalization changes update scale, so this isn't an optimizer leaderboard. Check that each run stays finite and both coordinate errors end smaller than they started. That's a basic sanity check, not a convergence guarantee.
1import math
2import numpy as np
3
4def loss(w: np.ndarray) -> float:
5 return float(0.5 * (100.0 * w[0] ** 2 + w[1] ** 2))
6
7def gradient(w: np.ndarray) -> np.ndarray:
8 return np.array([100.0 * w[0], w[1]])
9
10def cosine_lr(step: int, total_steps: int, peak_lr: float) -> float:
11 return peak_lr * 0.5 * (1.0 + math.cos(math.pi * step / (total_steps - 1)))
12
13def run_sgd(steps: int = 80) -> np.ndarray:
14 w = np.array([1.0, 1.0])
15 for step in range(steps):
16 w -= cosine_lr(step, steps, peak_lr=0.018) * gradient(w)
17 assert np.isfinite(loss(w)), "SGD became non-finite"
18 return w
19
20def run_adamw(steps: int = 80) -> np.ndarray:
21 w = np.array([1.0, 1.0])
22 m = np.zeros_like(w)
23 v = np.zeros_like(w)
24 beta1, beta2, eps, decay = 0.9, 0.999, 1e-8, 0.01
25 for step in range(1, steps + 1):
26 g = gradient(w)
27 m = beta1 * m + (1.0 - beta1) * g
28 v = beta2 * v + (1.0 - beta2) * g**2
29 m_hat = m / (1.0 - beta1**step)
30 v_hat = v / (1.0 - beta2**step)
31 lr = cosine_lr(step - 1, steps, peak_lr=0.08)
32 old_w = w.copy()
33 w -= lr * m_hat / (np.sqrt(v_hat) + eps)
34 w -= lr * decay * old_w
35 assert np.isfinite(loss(w)), "AdamW became non-finite"
36 return w
37
38for name, result in [("SGD", run_sgd()), ("AdamW", run_adamw())]:
39 assert np.all(np.abs(result) < 1.0), f"{name}: a coordinate didn't shrink"
40 print(name, "loss", round(loss(result), 6), "w", result.round(5).tolist())1SGD loss 0.117302 w [-0.0, 0.48436]
2AdamW loss 0.023509 w [0.02158, 0.02158]Why do AdamW's two coordinates end almost equal despite the 100:1 curvature? Starting from equal values, their gradient histories are nearly constant multiples of one another. Adam's numerator and denominator scale together, almost canceling that factor. SGD has no such normalization, so its b error remains much larger. This symmetry belongs to the constructed quadratic; real model selection still needs validation metrics.
Use PyTorch after understanding the state
In a neural-network training loop, PyTorch computes gradients by backpropagation and provides tested optimizer implementations.
Clear old gradients with zero_grad(), run backward(), clip before optimizer.step(), set this step's learning rate, then call step().
Capture the pre-clipping norm so you can see how often the guardrail fires. PyTorch's clip_grad_norm_() returns that total norm. Pass error_if_nonfinite=True so a NaN or Inf raises before it enters optimizer state.
This example uses the same two-coordinate loss, but adds clipping and runs for 60 updates rather than 80. Its final numbers therefore aren't a direct check against the unclipped NumPy run.
1import math
2import torch
3
4torch.manual_seed(0)
5w = torch.nn.Parameter(torch.tensor([1.0, 1.0]))
6optimizer = torch.optim.AdamW([w], lr=0.08, weight_decay=0.01)
7clipped_updates = 0
8max_gradient_norm = 0.0
9
10for step in range(60):
11 optimizer.zero_grad()
12 loss = 0.5 * (100.0 * w[0] ** 2 + w[1] ** 2)
13 loss.backward()
14 gradient_norm = torch.nn.utils.clip_grad_norm_([w], max_norm=20.0, error_if_nonfinite=True).item()
15 max_gradient_norm = max(max_gradient_norm, gradient_norm)
16 clipped_updates += int(gradient_norm > 20.0)
17 lr = 0.08 * 0.5 * (1.0 + math.cos(math.pi * step / 59))
18 optimizer.param_groups[0]["lr"] = lr
19 optimizer.step()
20
21final_loss = 0.5 * (100.0 * w[0] ** 2 + w[1] ** 2)
22print("loss", round(final_loss.item(), 6))
23print("w", [round(value, 5) for value in w.detach().tolist()])
24print("max pre-clip gradient norm", round(max_gradient_norm, 3))
25print("clipped updates", clipped_updates)1loss 0.047348
2w [0.03062, 0.03062]
3max pre-clip gradient norm 100.005
4clipped updates 11This example assigns the current update's learning rate directly before optimizer.step(). If you replace that assignment with a built-in PyTorch scheduler, call scheduler.step() after optimizer.step().
Calling it first skips the schedule's first value. Choose the scheduler's cadence deliberately: some advance per optimizer update, others per epoch, and ReduceLROnPlateau consumes a measured validation metric. PyTorch scheduler guidance
If you later use mixed precision with GradScaler, unscale the gradients before measuring or clipping them with scaler.unscale_(optimizer). Otherwise the clipping threshold applies to artificially scaled values. PyTorch AMP clipping example
Later training chapters will add minibatches, validation data, mixed precision, checkpoints, and distributed state. The optimizer logic you traced here remains inside that larger loop.
AdamW isn't the endpoint of optimizer research. For example, PyTorch 2.14 provides Muon, which treats two-dimensional hidden-layer weight matrices as matrices rather than independent coordinates. Its documentation recommends a method such as AdamW for other parameters, including biases and embeddings. A model can therefore use different optimizers for different parameter groups; compare the whole recipe under the same training budget and validation metric.
In the PyTorch loop, why must gradient clipping happen after loss.backward() but before optimizer.step()?
Answer
Backpropagation must create gradients before they can be measured or clipped, and clipping must modify them before the optimizer consumes them. Clipping after the step can't protect that update.
Optimizer state is also a memory budget
For each trained parameter, plain SGD has no moving-average buffer, momentum stores one buffer, and AdamW stores two (m and v). If both AdamW buffers use 32-bit floats, their cost is:
A model with 7 billion parameters therefore needs about 56 GB for those two buffers alone. Estimate that number before running the code:
1parameters = 7_000_000_000
2bytes_per_float32 = 4
3moment_buffers = 2
4bytes_used = parameters * bytes_per_float32 * moment_buffers
5
6print("AdamW moment buffers in GB:", bytes_used / 1_000_000_000)1AdamW moment buffers in GB: 56.0The 56 GB count omits parameters, gradients, activations, and any master-weight copies used by a training setup. At large scale, sharding optimizer state across devices is one reason systems such as ZeRO exist.[5]
Read optimizer failures from traces
A training curve gives you a symptom, not a cause. Collect a small evidence set before changing an optimizer:
| Symptom | Measure next | Candidate fix to test |
|---|---|---|
| loss becomes non-finite after one batch | gradient norm, batch contents, mixed-precision scale | correct bad data or numerics; compare clipping |
| loss oscillates while one parameter group barely changes | per-group update norm, learning rate, gradient scale | scale inputs or compare adaptive update |
| loss spikes at end of warmup | logged learning-rate boundary, gradient norm | lower peak rate or smooth the transition |
| training loss improves but validation worsens | validation metric, decay sweep, data leakage check | test regularization or stop earlier |
| run won't fit device memory | optimizer-state bytes and activation bytes | shard or reduce stored state |
Start with one failure hypothesis and log the quantity that could expose it. Change one mechanism, then re-run on the same data slice.
Worked checks
Use the same valley for each check. Predict first, then compare your work with the solution.
- Starting from
[a, b] = [1, 1], compute one SGD update withlr = 0.01. Does either coordinate cross zero? - At Adam step one, take gradient
[20, 0.2],lr = 0.001, and ignore epsilon. What is the bias-corrected update? - A global gradient has norm
50and threshold5. What scaling factor does clipping apply? - Your run clips 95 percent of minibatches. Is "clipping works" an adequate conclusion?
Solution checks:
- The gradient is
[100, 1]; the new vector is[0, 0.99]. Neither coordinate overshoots, but the second is still slow. - The update is
[0.001, 0.001]: first-step normalization removes magnitude differences for nonzero coordinates. - The factor is
5 / 50 = 0.1; the direction stays the same. - No. Persistent clipping is evidence that your threshold, learning rate, data, or numerical path needs inspection.