Learning rate
Step size, convergence and divergence.
Create a free account Try part of it here
Why it matters
The learning rate is the size of each training step. It's the one setting that most often makes training work or fail. Too small, and training crawls. Too large, and it blows up. Every recipe for training a large language model (LLM) names a learning rate and a plan for changing it: warm up, then slow down. When a fine-tuning run goes wrong, it's the first thing people check. A few minutes of feel for it here will save you hours of confused retraining.
What you'll be able to do
- Diagnose a learning rate that's too small, too large or about right
- Find a good learning rate with a quick sweep
- Explain learning-rate schedules: step decay, warmup and cosine decay
- Connect feature scaling to which learning rates are usable
What it covers
- Step size decides everything
- Worked example: ten steps at four rates
- Finding a good rate: the sweep
- Schedules: change the rate during training
- Scaling and the learning rate
Step size decides everything
Think of a shower with a stiff tap. You want warm water. Turn the knob a hair at a time, and you wait ages to get there. Wrench it hard, and the water goes scalding. You wrench it back, and now it's freezing. You swing from one extreme to the other. Somewhere in between, a firm turn gets you there fast and then you fine-tune.
Training works the same way. In the last lesson, each step moved the weights against the gradient:
- is the list of weights, the numbers the model adjusts.
- , said "grad L", is the gradient: the uphill direction for the loss.
- , said "eta", is the learning rate: how big a step to take.
The update is .
In plain words: new weights = old weights − learning rate × gradient. The gradient says which way is uphill. The learning rate scales every step, so it decides how far you move.
Here's one step on the bowl from Phase 2. The loss is , so the bottom is at . The slope is . Start at . The slope there is 2 × (−3.5) = −7. You're 3.5 away from the bottom.
- Rate 0.02: the step is 0.02 × 7 = 0.14. You land at −1.36, still far from 2.
- Rate 0.4: the step is 2.8. You land at 1.3, most of the way there.
- Rate 0.95: the step is 6.65. You land at 5.15, past the bottom. You're 3.15 away on the far side.
- Rate 1.05: the step is 7.35. You land at 5.85. That's 3.85 away, further than where you began.
w = -1.5 slope = 2 * (w - 2) # -7.0 for rate in [0.02, 0.4, 0.95, 1.05]: print(rate, round(w - rate * slope, 3))
Those four rates show the three regimes:
- Too small: each step barely moves. The loss falls, but painfully slowly. You may stop before reaching a good solution.
- About right: quick progress, then a smooth settle.
- Too large: steps overshoot the valley floor. The loss bounces. Past a limit, each step lands further away than the last. The loss grows until it becomes infinite. In practice you then see NaN, short for "not a number". It's what the computer prints when the arithmetic breaks down.
The rate 0.95 sits in between. It overshoots and bounces, but each bounce is a little smaller, so it still settles. Slowly.
Go deeper Where the limit comes from
On this bowl, one step multiplies your distance from the bottom by the same number every time: . You saw this in Phase 2.
- Rate 0.02: the multiplier is 0.96. You keep 96% of the distance each step. That's slow.
- Rate 0.4: the multiplier is 0.2. The distance shrinks fivefold each step.
- Rate 0.95: the multiplier is −0.9. The minus sign flips you to the other side, and 0.9 shrinks the distance a little. You bounce your way in.
- Rate 1.05: the multiplier is −1.1. You flip sides and get 10% further away each step.
Any multiplier between −1 and 1 shrinks the distance. On this bowl, that means any rate between 0 and 1 settles. A steeper bowl has a lower limit. Real loss surfaces have many directions, and the steepest one sets the limit for all of them.
Run 10 steps at 0.02 (too small). Then reset and try 0.4, 0.95 and 1.05. Watch the status line.
rate = 0.95 print(round(1 - 2 * rate, 2))
Show the answer
-0.9: you flip sides each step, but the distance shrinks, so it settles
1 − 1.9 = −0.9. The minus sign means you overshoot to the other side. Because 0.9 is less than 1, each bounce is smaller than the last, so the run still settles, slowly.
Worked example: ten steps at four rates
Same bowl, same start at , so you begin 3.5 away from the bottom at 2. Take ten steps at each of the four rates. Predict before you run it: which rate ends closest to 2, and which ends furthest away?
def slope(w): return 2 * (w - 2) # slope of the bowl (w - 2)² + 1 for rate in [0.02, 0.4, 0.95, 1.05]: w = -1.5 # same start every time for step in range(10): w = w - rate * slope(w) print(f"rate {rate:<5} w = {w:7.3f} distance from the bottom = {abs(w - 2):.3f}")
Rate 0.4 lands on 2.000: the distance shrank fivefold each step. Rate 0.02 is still 2.327 away after ten steps. It's moving the right way, but slowly. Rate 0.95 is 1.220 away: it bounced from side to side but got closer each time. Rate 1.05 ended 9.078 away, much further than where it started. Run it for longer and that number keeps growing until it overflows.
Finding a good rate: the sweep
You can't calculate the best rate ahead of time for a real model. So you test. Train briefly with several rates and compare the loss after a fixed number of steps. This is a learning-rate sweep.
Space the rates by factors of ten, with a stop in between: 0.001, 0.003, 0.01, 0.03, and so on. The best rate could be anywhere across a huge range, and this spacing covers it in a few tries.
The code below builds a small made-up dataset: 500 rows, three features and a column of ones for the intercept. Then it trains a linear model for 100 steps at each rate. The final_loss function gives up and returns infinity as soon as the weights blow up. The chart caps the loss at 20 so the failures stay on screen. Its x-axis uses a log scale, where each tick is ten times the one before.
import numpy as np import matplotlib.pyplot as plt rng = np.random.default_rng(0) X = np.column_stack([rng.normal(size=(500, 3)), np.ones(500)]) y = X @ np.array([2.0, -1.0, 0.5, 3.0]) + rng.normal(0, 0.5, 500) def final_loss(lr, steps=100): w = np.zeros(4) for _ in range(steps): w -= lr * 2 / len(y) * X.T @ (X @ w - y) if not np.all(np.isfinite(w)) or np.abs(w).max() > 1e6: return np.inf return np.mean((X @ w - y) ** 2) rates = [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1.0, 1.3] losses = [final_loss(r) for r in rates] for r, l in zip(rates, losses): print(f"lr {r:<6} loss {l:.3f}" if np.isfinite(l) else f"lr {r:<6} diverged") plt.plot(rates, [min(l, 20) for l in losses], marker="o") plt.xscale("log") plt.xlabel("learning rate") plt.ylabel("loss after 100 steps (capped at 20)")
Read the printout from top to bottom:
- 0.001 and 0.003 are too small. After 100 steps the loss is still 8.764 and 4.583.
- 0.01 is close, at 0.670.
- 0.03, 0.1 and 0.3 all reach 0.265, the bottom of this valley.
- 1.0 and 1.3 diverge: the loss runs off to infinity instead of settling.
On the log-scale chart, the curve is roughly U-shaped. The left side is too slow. The right side blows up. A wide good region sits in between.
Pick the largest rate that trains smoothly, or a little below it. Larger rates make faster progress. A little room below the cliff keeps you safe if the data or model changes slightly. Some deep learning libraries automate the same idea with a tool called a "learning-rate finder".
Show the answer
0.1 or 0.3: they train fully, with room to spare below the rate that blew up
0.1 and 0.3 reach the lowest loss in 100 steps and sit safely below 1.0, where training diverged. 0.01 is safe but slower.
Schedules: change the rate during training
Think of parking a car. You drive in at a decent speed, then creep the last few feet. One constant speed would be either too slow early or too fast at the end.
A constant learning rate has the same problem. Large early steps make fast progress. Near the end you want small steps that settle precisely. A schedule changes the rate as training goes on. Three are common:
- Step decay: cut the rate by a fixed factor every so many epochs. For example, start at 0.1 and divide by 10 every 30 epochs: 0.1, then 0.01, then 0.001. (An epoch, from the last lesson, is one pass over all the training data.)
- Warmup: start tiny and ramp up in a straight line over the first few hundred or few thousand steps. Early gradients in big networks can be large and erratic. Warmup avoids an early blow-up while the model finds its feet.
- Cosine decay: after warmup, glide down along a smooth curve to near zero by the end.
print(0.1 / 10)
Show the answer
0.01
One cut has happened by epoch 35, so the rate is 0.1 / 10 = 0.01. The second cut comes at epoch 60.
Cosine decay has a formula. First, the new symbols:
- is the step number, and is the learning rate at step .
- is the peak rate, the one you reach at the end of warmup.
- is the number of warmup steps.
- is the total number of steps in training.
- is the cosine function from trigonometry, and (pi) is about 3.14.
In plain words: the fraction inside measures progress through the decay phase. It runs from 0 at the end of warmup to 1 at the last step. The cosine part turns that progress into a smooth slide from 1 down to 0. Multiply by the peak rate, and the rate glides from the peak to zero. It falls gently at first, fastest in the middle, and gently again at the end.
You don't need to know how cosine works. For this formula, its input runs from 0 to π. Over that range, cosine slides smoothly from 1 down to −1. Adding 1 and halving turns that into a slide from 1 down to 0.
Try it with small numbers: a peak rate of 0.1, 10 warmup steps and 110 steps in total. So the decay phase runs from step 10 to step 110.
- Step 10: progress is 0. Cosine of 0 is 1, so the rate is 0.1 × ½ × (1 + 1) = 0.1, the peak.
- Step 60: progress is 50 / 100 = 0.5. Cosine of π/2 is 0, so the rate is 0.1 × ½ × 1 = 0.05.
- Step 110: progress is 1. Cosine of π is −1, so the rate is 0.1 × ½ × 0 = 0.
import math peak, warm, total = 0.1, 10, 110 for t in [10, 35, 60, 85, 110]: progress = (t - warm) / (total - warm) print(t, round(peak * 0.5 * (1 + math.cos(math.pi * progress)), 4))
It prints 0.1, 0.0854, 0.05, 0.0146 and 0.0. The rate halves by the midpoint and reaches zero at the end.
Warmup followed by cosine decay is the standard recipe for training and fine-tuning transformers, the kind of model behind LLMs.
lr0 = 0.1 epoch = 65 print(round(lr0 * 0.1 ** (epoch // 30), 6))
Show the answer
0.001
65 // 30 is 2, so two cuts have happened by epoch 65: 0.1 → 0.01 → 0.001.
Scaling and the learning rate
Back to the shower. Now picture one tap for hot and one for cold. The hot tap is very touchy: a tiny turn changes everything. The cold tap is very stiff. Now say one setting decides how hard you turn both taps. You're stuck. A setting gentle enough to keep the hot tap in check barely moves the cold one.
Unscaled features cause the same trap. Say one feature is order value, in hundreds of dollars. Another is the share of the order that was discounted, between 0 and 1. The loss is very steep along the order-value weight and very flat along the discount weight. The loss surface becomes a long, narrow valley. You saw this as the zig-zag 2D bowl in Phase 2.
A rate small enough to avoid blowing up in the steep direction barely moves the flat one. Training is slow and zig-zags. No single rate suits both weights.
Standardizing the features fixes this. You did it in the data-prep lesson: rescale each column so its average is 0 and its spread is 1. Every feature then has a similar size. The valley becomes rounder, so one learning rate suits every weight. Optimizers such as Adam, which you'll meet in Phase 4, also help. They give each weight its own step size.
Go deeper How much steeper?
The steepness along a weight grows with the square of its feature's size. Order values around 200 are about 1,000 times bigger than discount shares around 0.2. Square that, and the order-value direction is roughly a million times steeper.
The safe rate is set by the steepest direction. So the rate you can use is roughly a million times smaller than what the discount weight needs. That weight then crawls. These are rough sizes, not exact figures, but they show why unscaled data can make training look broken.
Show the answer
Lower the learning rate (and check feature scaling)
NaN usually means the weights exploded: the steps were too large for the loss surface. A smaller rate stops the overshooting, and scaled features keep any one direction from being far steeper than the rest.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
The loss falls very slowly but steadily for the whole run. What's likely?