Skip to content

Convexity, smoothness, gradient descent, and Armijo line search

ModuleM10.1 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/optim/gd.py: gradient_descent, armijo_step
Contractcourse/contracts/py/tinyllm/optim/gd.pyi
Testscourse/tests/M10.1/ (what they check: section 4)
Needsno code dependency · reading: M04.1 gradients, M03.4 eigenvalues of a symmetric matrix
Used byM10.2 (SGD with momentum 0 is this gradient descent) · later: L0.5 reads its loss curves with κ\kappa and 2/L2/L, M10.5 measures λmax⁡\lambda_{\max} during training, M09.3 generalizes κ\kappa to matrices
MilestoneMS-P2 (Pass 2 gate: every math module of the pass checks green, then your autograd bigram trains)
Optional depthBoyd and Vandenberghe, Convex Optimization, sections 3.1 and 9.2 to 9.3; Nocedal and Wright, Numerical Optimization (2nd ed.), ch. 3
  • On an LL-smooth, μ\mu-strongly convex function, gradient descent with step 1/L1/L shrinks the optimality gap by at least 1−1/κ1 - 1/\kappa per step, where κ=L/μ\kappa = L/\mu is the condition number (test_rate_on_quadratics).
  • On a quadratic, each eigen-direction is multiplied by 1−ηλi1 - \eta\lambda_i per step, so the iteration converges exactly when 0<η<2/L0 < \eta < 2/L and blows up beyond it (test_hand_example, test_divergence_raises).
  • Backtracking with the Armijo condition finds a step that decreases ff enough without knowing LL, and returns the first acceptable step of α0,ρα0,ρ2α0,…\alpha_0, \rho\alpha_0, \rho^2\alpha_0, \dots (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).
Terminal window
ol start M10.1 # stubs gd.py into your repo, contract alongside
ol tests M10.1 # read the test catalog first: rung R0, you write no tests here
ol check M10.1 # exit code is the verdict
ol diff M10.1 # after passing: your code against the reference

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 2/L2/L, and the number of steps scales with the condition number κ\kappa. 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.

SymbolMeaningType / shape
f:Rn→Rf: \mathbb{R}^n \to \mathbb{R}the function to minimize (a loss)callable
∇f(x)\nabla f(x)its gradient at xxfloat64[n]
x⋆x^\star, f⋆f^\stara minimizer and the minimum valuefloat64[n], float
η\eta (lr)the learning rate (step size)float >0> 0
xtx_tthe iterate after tt stepsfloat64[n]
LLsmoothness constant: ∥∇f(x)−∇f(y)∥≤L∥x−y∥\lVert\nabla f(x) - \nabla f(y)\rVert \le L \lVert x - y\rVertfloat
μ\mustrong convexity constantfloat >0> 0
κ=L/μ\kappa = L/\muthe condition numberfloat ≥1\ge 1
HHa symmetric positive definite matrix; for a quadratic, the Hessianfloat64[n, n]
λi\lambda_ieigenvalues of HH, with μ=λmin⁡\mu = \lambda_{\min} and L=λmax⁡L = \lambda_{\max}floats
dda search directionfloat64[n]
α\alpha, α0\alpha_0, ρ\rho, cctrial step, first trial, shrink factor, sufficient-decrease constantfloats

Convexity. ff is convex if the chord between any two points of its graph lies above the graph: f(θx+(1−θ)y)≤θf(x)+(1−θ)f(y)f(\theta x + (1-\theta) y) \le \theta f(x) + (1-\theta) f(y) for θ∈[0,1]\theta \in [0, 1]. For differentiable ff this is equivalent to every tangent plane lying below the graph:

f(y)≥f(x)+∇f(x)⊤(y−x).f(y) \ge f(x) + \nabla f(x)^\top (y - x).

Then ∇f(x⋆)=0\nabla f(x^\star) = 0 implies f(y)≥f(x⋆)f(y) \ge f(x^\star) for all yy: 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 LL-Lipschitz, the function never curves up faster than a parabola of curvature LL (the descent lemma):

f(y)≤f(x)+∇f(x)⊤(y−x)+L2∥y−x∥2.f(y) \le f(x) + \nabla f(x)^\top (y - x) + \tfrac L2 \lVert y - x\rVert^2.

Strong convexity bounds it from below. ff is μ\mu-strongly convex if f(y)≥f(x)+∇f(x)⊤(y−x)+μ2∥y−x∥2f(y) \ge f(x) + \nabla f(x)^\top (y - x) + \tfrac\mu2 \lVert y - x\rVert^2: it curves up at least as fast as a parabola of curvature μ\mu. For a quadratic f(x)=12x⊤Hxf(x) = \tfrac12 x^\top H x, the gradient is HxHx, and the best constants are the extreme eigenvalues of HH (M03.4): L=λmax⁡L = \lambda_{\max}, μ=λmin⁡\mu = \lambda_{\min}.

The condition number. κ=L/μ\kappa = L/\mu measures how stretched the level sets are: κ=1\kappa = 1 is a round bowl, κ=100\kappa = 100 is a long narrow valley. M09.3 later defines the condition number of a matrix, ∥A∥∥A−1∥\lVert A\rVert \lVert A^{-1}\rVert; for a symmetric positive definite HH it is the same λmax⁡/λmin⁡\lambda_{\max}/\lambda_{\min}.

Gradient descent and its rate. The update is xt+1=xt−η∇f(xt)x_{t+1} = x_t - \eta \nabla f(x_t). Put y=xt+1y = x_{t+1} in the descent lemma with η=1/L\eta = 1/L:

f(xt+1)≤f(xt)−12L∥∇f(xt)∥2.f(x_{t+1}) \le f(x_t) - \tfrac{1}{2L} \lVert\nabla f(x_t)\rVert^2.

Strong convexity implies ∥∇f(x)∥2≥2μ (f(x)−f⋆)\lVert\nabla f(x)\rVert^2 \ge 2\mu\,(f(x) - f^\star) (minimize both sides of its defining inequality over yy). Combining,

f(xt+1)−f⋆≤(1−μL)(f(xt)−f⋆)⇒f(xt)−f⋆≤(1−1κ)t(f(x0)−f⋆).f(x_{t+1}) - f^\star \le \left(1 - \frac{\mu}{L}\right)\left(f(x_t) - f^\star\right) \quad\Rightarrow\quad f(x_t) - f^\star \le \left(1 - \frac1\kappa\right)^t \left(f(x_0) - f^\star\right).

To shrink the gap by a factor ε\varepsilon takes about κln⁡(1/ε)\kappa \ln(1/\varepsilon) steps: ten times the condition number, ten times the steps.

The quadratic, exactly. For f=12x⊤Hxf = \tfrac12 x^\top H x, write xx in the eigenvectors of HH. Gradient descent is then independent in each coordinate:

xt+1,i=(1−ηλi) xt,i.x_{t+1, i} = (1 - \eta \lambda_i)\, x_{t, i}.

The coordinate shrinks when ∣1−ηλi∣<1\lvert 1 - \eta\lambda_i\rvert < 1, that is 0<η<2/λi0 < \eta < 2/\lambda_i. For all coordinates at once: 0<η<2/L0 < \eta < 2/L. Above 2/L2/L the stiffest direction grows by ∣1−ηL∣>1\lvert 1 - \eta L\rvert > 1 per step, which is the loss that jumps to inf. At η=1/L\eta = 1/L the stiffest direction is solved in one step and the flattest shrinks by 1−1/κ1 - 1/\kappa: the bound above is attained.

Line search: a step without knowing LL. Real losses do not announce LL. Given a direction dd with ∇f(x)⊤d<0\nabla f(x)^\top d < 0 (a descent direction, for example d=−∇f(x)d = -\nabla f(x)), try α=α0\alpha = \alpha_0 and accept it if the Armijo condition holds:

f(x+αd)≤f(x)+c α ∇f(x)⊤d,0<c<1.f(x + \alpha d) \le f(x) + c\,\alpha\, \nabla f(x)^\top d, \qquad 0 < c < 1.

The right side is a line through f(x)f(x) with a fraction cc of the initial slope; the step must land below it. Otherwise set α←ρα\alpha \leftarrow \rho\alpha and try again. For an LL-smooth ff and d=−∇fd = -\nabla f, the descent lemma shows every α≤2(1−c)/L\alpha \le 2(1-c)/L is accepted, so backtracking stops after a bounded number of halvings with a step of at least 2ρ(1−c)/L2\rho(1-c)/L. If dd 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.

Gradient descent on f(x)=12(x12+10x22)f(x) = \tfrac12(x_1^2 + 10 x_2^2). H=diag(1,10)H = \mathrm{diag}(1, 10), so μ=1\mu = 1, L=10L = 10, κ=10\kappa = 10, and ∇f=(x1,10x2)\nabla f = (x_1, 10 x_2). Start at x0=(10,1)x_0 = (10, 1) with η=1/L=0.1\eta = 1/L = 0.1:

ttxtx_t∇f(xt)\nabla f(x_t)f(xt)f(x_t)f(xt)/f(xt−1)f(x_t)/f(x_{t-1})
0(10,1)(10, 1)(10,10)(10, 10)55
1(9,0)(9, 0)(9,0)(9, 0)40.50.736
2(8.1,0)(8.1, 0)(8.1,0)(8.1, 0)32.8050.81

The stiff coordinate is multiplied by 1−0.1⋅10=01 - 0.1 \cdot 10 = 0 and is done in one step; the flat one by 1−0.1⋅1=0.91 - 0.1 \cdot 1 = 0.9, so its share of ff shrinks by 0.810.81 per step, inside the bound 1−1/κ=0.91 - 1/\kappa = 0.9. With η=0.21\eta = 0.21, just over 2/L=0.22/L = 0.2, the stiff coordinate is multiplied by 1−2.1=−1.11 - 2.1 = -1.1 per step: it alternates sign and grows without bound.

One Armijo search on f(x)=x2f(x) = x^2 at x=1x = 1 along d=−f′(1)=−2d = -f'(1) = -2, with α0=1\alpha_0 = 1, c=10−4c = 10^{-4}, ρ=0.5\rho = 0.5. The slope is f′(1) d=−4f'(1)\,d = -4.

α\alphax+αdx + \alpha dffbound 1+c α (−4)1 + c\,\alpha\,(-4)accept?
1−1-110.9996no
0.5000.9998yes

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.

python/tinyllm/optim/gd.py
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) -> float

gradient_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 0<c<10 < c < 1, 0<ρ<10 < \rho < 1, α0>0\alpha_0 > 0, and RuntimeError after 60 reductions without success.

TestKINDChecksWhy it matters downstream
test_hand_exampleunitthe section 3 trajectory and the Armijo searchyou and the test agree on the update
test_returns_every_iterate_as_copiesunitsteps + 1 independent arrays, x0 untouchedtrajectories are data you plot and compare
test_rate_on_quadraticspropertygap ≤(1−1/κ)t\le (1 - 1/\kappa)^t at every step for κ\kappa = 2, 10, 100the theorem behind learning-rate choices
test_exact_rational_trajectorydifferentialequals an exact fractions.Fraction recomputationthe float trajectory is the math, to rounding
test_divergence_raisesboundaryη=3>2/L\eta = 3 > 2/L raises FloatingPointError; η=0.19\eta = 0.19 convergesa diverging run fails loudly
test_rejects_bad_argumentsboundaryη≤0\eta \le 0 and negative steps are ValueErrorcaller bugs
test_armijo_returns_the_first_acceptable_steppropertythe step satisfies the condition and twice it does not, on 50 Rosenbrock pointsthe largest acceptable step on the grid
test_armijo_accepts_alpha0unita full step that works is returned unchangedno needless shrinking
test_armijo_keeps_halvingunitf=x2f = x^2 at 1 along −4-4 needs two halvings and gets α=0.25\alpha = 0.25each trial shrinks the last one
test_armijo_rejects_nan_stepsboundaryNaN trial values are rejected; the search returns 0.125functions with a restricted domain
test_armijo_needs_a_descent_directionboundaryascent and orthogonal directions, bad cc, ρ\rho, α0\alpha_0 raiseno infinite halving
test_armijo_gives_upboundarya lying gradient ends in RuntimeError; the 60th halving, 2−602^{-60}, is still triedno infinite loop, and no reduction skipped
test_line_search_descends_rosenbrockproperty300 steepest-descent steps with Armijo decrease ff monotonicallya step size without knowing LL
PitfallSymptomCaught by
1. a learning rate above 2/L2/L with no divergence checka list of inf and NaN returned as a trajectorytest_divergence_raises (mutant s05)
2. while f(new) > bounda NaN trial point is acceptedtest_armijo_rejects_nan_steps (mutant s07)
3. searching along a direction that does not descendhalving forever, or accepting an ascenttest_armijo_needs_a_descent_direction (mutant s08)
4. updating x in place and appending itevery entry of the trajectory is the final iterate; x0 changestest_returns_every_iterate_as_copies (mutants s03, s04)
5. the sign of the stepgradient ascenttest_hand_example (mutant s01)
6. the sign of the Armijo boundsteps that increase ff are acceptedtest_armijo_returns_the_first_acceptable_step (mutant s06)
7. shrinking once more after successsteps half as long as they could betest_armijo_accepts_alpha0 (mutant s09)
8. returning a vanishing step instead of giving upsilent stalls at α=10−18\alpha = 10^{-18}test_armijo_gives_up (mutant s10)
9. leaving x0x_0 out of the trajectoryevery index is off by onetest_hand_example (mutant s02)
10. restarting each trial from α0\alpha_0, alpha = alpha0 * rhoonly α0\alpha_0 and ρα0\rho\alpha_0 are ever tried; a search that needs two halvings gives uptest_armijo_keeps_halving (mutant s12)
DirectionModuleHow it uses this
BackM04.1gradients, and how to check them
BackM03.4the eigenvalues that give LL, μ\mu, and κ\kappa
ForwardM10.2SGD with momentum 0 performs exactly this update, in place, behind the Optimizer protocol
ForwardL0.5the bigram’s learning rate and loss curve, read with 2/L2/L and κ\kappa
ForwardM10.5the top Hessian eigenvalue during training, against the 2/η2/\eta edge of stability
ForwardM09.3the 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.

Your pieceProduction equivalentWhat it addsWhere to look
armijo_stepscipy.optimize.line_searchthe strong Wolfe conditions (a curvature test as well as Armijo) with interpolation instead of halvingscipy/optimize/_linesearch.py
line search in a training loopPyTorch torch.optim.LBFGS(line_search_fn="strong_wolfe")quasi-Newton directions that undo much of κ\kappatorch/optim/lbfgs.py
gradient descent on quadraticsconjugate gradient (M09.7)κ\sqrt\kappa instead of κ\kappa steps on quadratics, by choosing HH-orthogonal directionsNocedal and Wright, ch. 5