Convexity, smoothness, gradient descent, and Armijo line search
Overview
Section titled “Overview”| Module | M10.1 · build · Python · Pass 2 · 2 to 3 h |
| You build | python/tinyllm/optim/gd.py: gradient_descent, armijo_step |
| Contract | course/contracts/py/tinyllm/optim/gd.pyi |
| Tests | course/tests/M10.1/ (what they check: section 4) |
| Needs | no code dependency · reading: M04.1 gradients, M03.4 eigenvalues of a symmetric matrix |
| Used by | M10.2 (SGD with momentum 0 is this gradient descent) · later: L0.5 reads its loss curves with and , M10.5 measures during training, M09.3 generalizes to matrices |
| Milestone | MS-P2 (Pass 2 gate: every math module of the pass checks green, then your autograd bigram trains) |
| Optional depth | Boyd and Vandenberghe, Convex Optimization, sections 3.1 and 9.2 to 9.3; Nocedal and Wright, Numerical Optimization (2nd ed.), ch. 3 |
Key Takeaways
Section titled “Key Takeaways”- On an -smooth, -strongly convex function, gradient descent with step shrinks the optimality gap by at least per step, where is the condition number (
test_rate_on_quadratics). - On a quadratic, each eigen-direction is multiplied by per step, so the iteration converges exactly when and blows up beyond it (
test_hand_example,test_divergence_raises). - Backtracking with the Armijo condition finds a step that decreases enough without knowing , and returns the first acceptable step of (
test_armijo_returns_the_first_acceptable_step). - Numerical edge cases are part of the algorithm: a NaN trial value must be rejected, and a direction that does not descend must be refused (
test_armijo_rejects_nan_steps,test_armijo_needs_a_descent_direction).
How to work this chapter
Section titled “How to work this chapter”ol start M10.1 # stubs gd.py into your repo, contract alongsideol tests M10.1 # read the test catalog first: rung R0, you write no tests hereol check M10.1 # exit code is the verdictol diff M10.1 # after passing: your code against the reference1. Why now
Section titled “1. Why now”In L0.5 your bigram stops being a table of counts and becomes 65536 weights trained by your own autograd, and the first decision is the learning rate. Too large and the loss jumps to inf, then NaN, within a few steps; too small and it crawls for thousands of steps toward the count model’s NLL that L0.0 reached instantly. Both symptoms have exact explanations on a quadratic, the local model of every smooth loss: the step must stay under , and the number of steps scales with the condition number . This module builds plain gradient descent and a line search, proves their rates on functions where the theory is exact, and gives M10.2’s optimizer protocol the update it generalizes.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| the function to minimize (a loss) | callable | |
| its gradient at | float64[n] | |
| , | a minimizer and the minimum value | float64[n], float |
(lr) | the learning rate (step size) | float |
| the iterate after steps | float64[n] | |
| smoothness constant: | float | |
| strong convexity constant | float | |
| the condition number | float | |
| a symmetric positive definite matrix; for a quadratic, the Hessian | float64[n, n] | |
| eigenvalues of , with and | floats | |
| a search direction | float64[n] | |
| , , , | trial step, first trial, shrink factor, sufficient-decrease constant | floats |
Convexity. is convex if the chord between any two points of its graph lies above the graph: for . For differentiable this is equivalent to every tangent plane lying below the graph:
Then implies for all : every stationary point is a global minimum. Neural network losses are not convex, but near a minimum they look like convex quadratics, and that is where the rates below describe what you see.
Smoothness bounds the function from above. If the gradient is -Lipschitz, the function never curves up faster than a parabola of curvature (the descent lemma):
Strong convexity bounds it from below. is -strongly convex if : it curves up at least as fast as a parabola of curvature . For a quadratic , the gradient is , and the best constants are the extreme eigenvalues of (M03.4): , .
The condition number. measures how stretched the level sets are: is a round bowl, is a long narrow valley. M09.3 later defines the condition number of a matrix, ; for a symmetric positive definite it is the same .
Gradient descent and its rate. The update is . Put in the descent lemma with :
Strong convexity implies (minimize both sides of its defining inequality over ). Combining,
To shrink the gap by a factor takes about steps: ten times the condition number, ten times the steps.
The quadratic, exactly. For , write in the eigenvectors of . Gradient descent is then independent in each coordinate:
The coordinate shrinks when , that is . For all coordinates at once: . Above the stiffest direction grows by per step, which is the loss that jumps to inf. At the stiffest direction is solved in one step and the flattest shrinks by : the bound above is attained.
Line search: a step without knowing . Real losses do not announce . Given a direction with (a descent direction, for example ), try and accept it if the Armijo condition holds:
The right side is a line through with a fraction of the initial slope; the step must land below it. Otherwise set and try again. For an -smooth and , the descent lemma shows every is accepted, so backtracking stops after a bounded number of halvings with a step of at least . If is not a descent direction, no small step helps, and the search must refuse instead of halving forever.
NaN is a rejection. A trial point outside the function’s domain (a log of a negative number, an overflow) gives nan, and every comparison with NaN is false. Written as if f(x + a d) <= bound: return a, a NaN fails the test and the step shrinks. Written as while f(x + a d) > bound: a *= rho, the NaN makes the loop condition false and the bad step is accepted. Same math, opposite behavior.
3. Worked example by hand
Section titled “3. Worked example by hand”Gradient descent on . , so , , , and . Start at with :
| 0 | 55 | |||
| 1 | 40.5 | 0.736 | ||
| 2 | 32.805 | 0.81 |
The stiff coordinate is multiplied by and is done in one step; the flat one by , so its share of shrinks by per step, inside the bound . With , just over , the stiff coordinate is multiplied by per step: it alternates sign and grows without bound.
One Armijo search on at along , with , , . The slope is .
| bound | accept? | |||
|---|---|---|---|---|
| 1 | 1 | 0.9996 | no | |
| 0.5 | 0 | 0 | 0.9998 | yes |
The full step overshoots to the mirror point with the same value; half of it lands on the minimum. The search returns 0.5.
These numbers are the first test case in section 4, test_hand_example.
4. The interface
Section titled “4. The interface”def gradient_descent(f, grad, x0, lr: float, steps: int) -> list[NDArray] # [x_0, ..., x_steps]def armijo_step(f, grad, x, d, alpha0: float = 1.0, c: float = 1e-4, rho: float = 0.5) -> floatgradient_descent returns steps + 1 independent float64 arrays and never modifies x0. It evaluates f at each iterate only to detect divergence: a non-finite value raises FloatingPointError naming the step. armijo_step raises ValueError for a non-descent direction or parameters outside , , , and RuntimeError after 60 reductions without success.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example | unit | the section 3 trajectory and the Armijo search | you and the test agree on the update |
test_returns_every_iterate_as_copies | unit | steps + 1 independent arrays, x0 untouched | trajectories are data you plot and compare |
test_rate_on_quadratics | property | gap at every step for = 2, 10, 100 | the theorem behind learning-rate choices |
test_exact_rational_trajectory | differential | equals an exact fractions.Fraction recomputation | the float trajectory is the math, to rounding |
test_divergence_raises | boundary | raises FloatingPointError; converges | a diverging run fails loudly |
test_rejects_bad_arguments | boundary | and negative steps are ValueError | caller bugs |
test_armijo_returns_the_first_acceptable_step | property | the step satisfies the condition and twice it does not, on 50 Rosenbrock points | the largest acceptable step on the grid |
test_armijo_accepts_alpha0 | unit | a full step that works is returned unchanged | no needless shrinking |
test_armijo_keeps_halving | unit | at 1 along needs two halvings and gets | each trial shrinks the last one |
test_armijo_rejects_nan_steps | boundary | NaN trial values are rejected; the search returns 0.125 | functions with a restricted domain |
test_armijo_needs_a_descent_direction | boundary | ascent and orthogonal directions, bad , , raise | no infinite halving |
test_armijo_gives_up | boundary | a lying gradient ends in RuntimeError; the 60th halving, , is still tried | no infinite loop, and no reduction skipped |
test_line_search_descends_rosenbrock | property | 300 steepest-descent steps with Armijo decrease monotonically | a step size without knowing |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. a learning rate above with no divergence check | a list of inf and NaN returned as a trajectory | test_divergence_raises (mutant s05) |
2. while f(new) > bound | a NaN trial point is accepted | test_armijo_rejects_nan_steps (mutant s07) |
| 3. searching along a direction that does not descend | halving forever, or accepting an ascent | test_armijo_needs_a_descent_direction (mutant s08) |
4. updating x in place and appending it | every entry of the trajectory is the final iterate; x0 changes | test_returns_every_iterate_as_copies (mutants s03, s04) |
| 5. the sign of the step | gradient ascent | test_hand_example (mutant s01) |
| 6. the sign of the Armijo bound | steps that increase are accepted | test_armijo_returns_the_first_acceptable_step (mutant s06) |
| 7. shrinking once more after success | steps half as long as they could be | test_armijo_accepts_alpha0 (mutant s09) |
| 8. returning a vanishing step instead of giving up | silent stalls at | test_armijo_gives_up (mutant s10) |
| 9. leaving out of the trajectory | every index is off by one | test_hand_example (mutant s02) |
10. restarting each trial from , alpha = alpha0 * rho | only and are ever tried; a search that needs two halvings gives up | test_armijo_keeps_halving (mutant s12) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M04.1 | gradients, and how to check them |
| Back | M03.4 | the eigenvalues that give , , and |
| Forward | M10.2 | SGD with momentum 0 performs exactly this update, in place, behind the Optimizer protocol |
| Forward | L0.5 | the bigram’s learning rate and loss curve, read with and |
| Forward | M10.5 | the top Hessian eigenvalue during training, against the edge of stability |
| Forward | M09.3 | the condition number of a matrix and of a problem |
If you skip this module, ol check M10.2 stops with M10.2 needs M10.1: build it, or rerun with --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
armijo_step | scipy.optimize.line_search | the strong Wolfe conditions (a curvature test as well as Armijo) with interpolation instead of halving | scipy/optimize/_linesearch.py |
| line search in a training loop | PyTorch torch.optim.LBFGS(line_search_fn="strong_wolfe") | quasi-Newton directions that undo much of | torch/optim/lbfgs.py |
| gradient descent on quadratics | conjugate gradient (M09.7) | instead of steps on quadratics, by choosing -orthogonal directions | Nocedal and Wright, ch. 5 |