Gradient Descent and Numerical Optimization
Learn how gradient descent updates parameters on quadratic objectives, how curvature and learning rates control convergence, and how to verify least-squares fits in NumPy.
Machine Learning from ScratchAll chapters Gradient Descent and Numerical OptimizationFull chapter · Sections in this chapter1 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Gradient Descent and Numerical Optimization
A decreasing loss can still leave the parameters far from their best values. A learning rate may make steady progress in one direction while barely moving in another; a larger rate can overshoot and diverge. On a quadratic objective, we can calculate these behaviors exactly and use them to check an implementation.
Gradient descent repeatedly measures the local slope and moves against it. For a differentiable objective with a nonzero gradient, a sufficiently small step in that direction decreases the objective. The gradient determines a direction; the learning rate determines the size of the displacement. Neither statement promises a global solution on an arbitrary nonconvex surface.
We will derive this update, compare two quadratics with controlled curvature, and contrast their paths with a Newton step from the same starting point. We will then fit a small least-squares model, check its gradient numerically, and examine batch, single-example, and mini-batch updates. The examples connect the objective from Sum of Squared Errors (SSE) with the coordinate changes introduced in standardization. The stopping rules will tell us what each run established.
Advantages
The update needs an objective and its gradient rather than a closed-form minimizer. We can therefore use it for smooth fitting problems such as logistic regression, using negative log-likelihood as the objective. For an unconstrained problem, the same update structure applies when we change the loss and compute its new gradient. Nondifferentiable objectives require an appropriate alternative, such as a subgradient or proximal method.
For the dense linear model used here, a full gradient costs operations for rows and parameters. A single-example or mini-batch update can use less data before changing the parameters. These first-order updates avoid forming a Hessian, and data can be supplied in batches when storing the full design is inconvenient.
An iterative fit also produces a history that we can inspect. We can record the objective, gradient norm, parameter changes, and stopping reason. Computing these diagnostics has a cost, and an increasing objective can be expected during stochastic updates, so the appropriate interpretation depends on the algorithm.
Smooth penalties can be included in the objective and differentiated along with the data-fit term. Constraints need additional treatment, such as a projection or a solver designed for the feasible set; an unconstrained gradient step alone does not enforce them.
Disadvantages
Gradient descent uses local information. On a nonconvex objective, a small gradient can occur at a local minimum, a saddle, or a local maximum. A run may also diverge or reach its iteration limit. Its stopping status alone does not establish global optimality.
Curvature can make a stable run slow. For a positive-definite quadratic, the worst-case convergence rate depends on the Hessian condition number and the chosen learning rate. Large condition numbers make the best fixed-rate contraction approach one. The effect of rescaling features depends on their variances and correlations; it is not generally proportional to the change in their units.
We must also choose an update budget, stopping tolerances, and, for stochastic methods, a sampling rule and step schedule. Curvature sometimes provides a principled learning rate, while other problems need a line search or empirical tuning. Poor choices can cause divergence or stop a run before it reaches a useful fit.
Formula
Let be the parameter vector and let be the real-valued objective to minimize. We begin with a local approximation to , then use quadratic examples to determine exactly when repeated updates converge.
The entries of are the quantities we fit. In the regression example they will be an intercept and slopes; the objective is half the mean squared prediction error. Unless stated otherwise, the derivations concern differentiable objectives on an unconstrained parameter space.
The first thing we need is a compact summary of how responds to a nudge in any of the coordinate directions. That summary is the gradient, which collects all first partial derivatives into one vector:
Read the vector one entry at a time and each entry asks the same question in a different coordinate: if we nudge only by a tiny amount and hold the other parameters fixed, how fast does change, and in which direction? Stacking those answers into a column vector turns separate one-dimensional sensitivities into a single object that encodes both the steepest direction and the steepness itself. Two facts about that object carry everything that follows:
- For a nonzero gradient, its negative gives the steepest directional decrease among directions of unit Euclidean length. This is a first-order statement about a small displacement, not the best destination at an arbitrary finite distance.
- The gradient norm measures the first-order residual in the chosen units. A differentiable unconstrained local minimum has zero gradient, but a small norm alone does not measure distance to a minimizer or certify that a point is a minimum.
A direction alone does not tell us how far to move. The learning rate supplies that scale.
Gradients and parameter updates
Let be a direction vector and a scalar step multiplier. A first-order Taylor expansion around the current point says
The left side is the objective after moving by . The right side adds a first-order prediction of that change to the current objective. The dot product is the directional derivative along . When both vectors are nonzero, it is negative if their angle exceeds ninety degrees. Among unit Euclidean directions, Cauchy-Schwarz gives the smallest dot product at , provided the gradient is nonzero. Absorbing the step length into a positive scalar gives the gradient-descent update
where:
- : the parameter vector at iteration
- : the updated parameter vector after one step
- : the learning rate, controlling how far we travel along the negative gradient
- : the gradient of the objective evaluated at the current parameters, pointing in the direction of steepest increase
- : the iteration counter
Reading it plainly: the new parameters are the old parameters, shifted a small distance against the gradient. In scalar form, component obeys , where is the -th component after the update, is the -th component before the update, is the learning rate, and is the partial derivative of the objective with respect to the -th parameter. This component form is often easier to reason about when we implement it.
The displacement has Euclidean length : the learning rate multiplies the gradient rather than directly specifying a distance. For example, when fitting a likelihood model we minimize its negative log-likelihood and differentiate that objective. A local linear approximation need not remain accurate over a large displacement, so the step length requires a separate choice.
Gradients and parameter updates on a quadratic
For the scalar quadratic with , the gradient is . Substituting it into the update gives
where:
- : the parameter at iteration
- : the curvature, the second derivative of with respect to
- : the learning rate
- : the signed multiplier of the scalar parameter at each update
The quantity multiplies the parameter anew at every iteration, which means after steps
where is the starting value at iteration zero.
For every starting value, convergence to zero occurs when , equivalently . At , a single update reaches zero. For a nonzero starting value, produces constant-amplitude sign changes, and produces divergence. A start already at zero stays there for any finite learning rate. These exact recursions use the constant curvature of this quadratic; a general smooth objective needs a separate local or global analysis.
Learning rates, curvature, and feature scaling
The relevant step scale is reciprocal curvature. For , larger curvature narrows the stable interval . This explains why a rate that works on one quadratic may be unstable on another. It does not imply that every small rate is equally efficient.
For a quadratic with a constant symmetric positive-definite Hessian , all eigenvalues satisfy . Write the error relative to its unique minimizer in the orthonormal eigenvector basis of . Each error coordinate is multiplied by , so the Euclidean error contracts by at most . Convergence from every start requires . Balancing the two extreme multipliers gives the optimal fixed rate and its worst-case parameter-error contraction factor
where:
- : the condition number of the Hessian, defined as , which measures how differently the objective curves across directions
- : the smallest and largest eigenvalues of the Hessian , the curvatures along the shallowest and steepest directions respectively
- : the worst-case per-iteration error contraction factor for the optimal fixed learning rate
When , this quadratic has equal curvature in every direction and the optimal fixed rate reaches the minimizer in one update. At , the factor is ; the worst-case parameter error needs 116 updates for a tenfold reduction because . This comparison uses relative parameter error and the optimal fixed rate. Objective gaps along an individual eigenmode contract by the square of that mode's parameter multiplier.
For , differentiation gives gradient and Hessian . Here has rows and columns, has entries, and has entries. If has full column rank, the Hessian is positive definite and its spectral condition number equals the square of the design matrix's spectral condition number. Rescaling feature columns changes this geometry. Standardization often helps when unequal feature scales cause poor conditioning, but it does not remove collinearity or guarantee fast convergence. With rank deficiency, the Hessian is singular and the positive-condition-number formulas above do not apply.
Batch, stochastic, and mini-batch descent
When is a finite mean of differentiable example losses, differentiation gives . The full-batch method computes this complete average before an update. Its work is times the cost of one example gradient; for the dense linear model here that is .
A single-example update uses one loss gradient. If a fresh index is sampled uniformly and independently of previous updates, its gradient at the current fixed parameters has conditional expectation equal to the full gradient. The update is
A single-example gradient can vary around the full gradient, so individual updates need not decrease the full objective. Persistent noise near an optimum can leave fluctuations when the step stays constant. Positive schedules satisfying , , and are used in convergence analyses together with assumptions on the objective and noise. A smooth strongly convex objective, conditionally unbiased bounded-variance gradients, and stable iterates are one such setting; the schedule alone is not a guarantee for an arbitrary objective. Noise can also vanish at the solution, in which case convergence with a constant step is possible. Our implementation uses random reshuffling, whose adaptive updates are not automatically covered by the fresh-index unbiasedness calculation.
Mini-batch descent sits between the extremes, using a batch of size :
At fixed parameters, averaging independent example gradients divides their covariance by . Sampling a subset without replacement has a different finite-population factor and gives zero sampling variance when the batch contains all examples. Batch size also changes the number of updates per pass and the available vectorization. We use 32 as one illustrative mini-batch size below; it is not a universal optimum.
Newton steps, stopping rules, and numerical checks
If curvature is the problem, we can use it. Rather than guessing a step size that survives the worst direction, expand the objective to second order around the current point:
The Hessian is the matrix of second partial derivatives. The new quadratic term models how the slope changes over the displacement . When this Hessian is positive definite, the quadratic approximation has a unique minimizer. Differentiating that approximation with respect to and setting its gradient to zero gives
So the Newton update is
The displayed rule is the full Newton step, with step multiplier one. In practice we solve rather than explicitly forming an inverse. On a positive-definite quadratic this reaches the minimizer in one update in exact arithmetic. Local quadratic convergence for a nonquadratic objective is guaranteed under a sufficiently close start, a nonsingular Hessian at the solution, and a Hessian that is locally Lipschitz, meaning its changes are bounded proportionally to parameter displacement nearby. These sufficient conditions are stronger than smoothness alone.
A dense direct solve typically costs arithmetic and stores a matrix; forming the Hessian has an additional cost. Newton-type methods can also use iterative solves or Hessian-vector products, so explicit dense storage is not universal. An indefinite Hessian can produce an uphill direction. A shift must be large enough to make the modified Hessian positive definite; at a nonzero gradient that provides a descent direction. A line search can then shorten a descent step to achieve sufficient decrease. Shortening an uphill direction alone does not repair its sign.
For stopping, we need honest criteria rather than a fixed iteration count. Three are standard, and they answer different questions:
- Absolute gradient norm checks an approximate first-order stationarity condition in the current units.
- Relative objective change asks whether progress has stalled.
- Relative parameter change asks whether the iterates have settled.
Each can misfire alone. A tiny objective change may reflect a flat but nonoptimal plateau, and a small gradient norm may occur at a saddle. Report more than one diagnostic alongside the objective value at the returned point. A finite-difference estimate provides a separate check on a hand-derived gradient: it can expose a sign, normalization, or indexing mistake before we interpret the training curve.
Visualizing Gradient Descent on a Conditioned Quadratic
We use two diagonal quadratic objectives with known minima to inspect the update paths. For a two-entry parameter vector, define
The positive coefficients and are the curvatures along the coordinate axes. The gradient is , the Hessian is , and the unique minimizer is the origin. Here , so the condition number is .
PythonIn [3]Expand
import numpy as np
# Two diagonal quadratics: J(theta) = 0.5 * (a1 * theta1^2 + a2 * theta2^2)
# The Hessian is diag(a1, a2), so the condition number is a1 / a2 exactly.
well = np.array([2.0, 1.0])
ill = np.array([100.0, 1.0])
def objective(theta, a):
"""Scalar objective value for a diagonal quadratic."""
return 0.5 * np.sum(a * theta**2)
def gradient(theta, a):
"""Exact gradient: component j is a_j * theta_j."""
return a * theta
def run_gd(theta0, a, eta, steps):
"""Plain gradient descent, recording the full path for plotting."""
theta = np.array(theta0, dtype=float)
path = [theta.copy()]
history = [objective(theta, a)]
for _ in range(steps):
theta = theta - eta * gradient(theta, a)
path.append(theta.copy())
history.append(objective(theta, a))
return np.array(path), np.array(history)
theta0 = np.array([1.0, 1.0])
# Stable step for both problems: eta < 2 / lambda_max is required.
stability_info = []
for name, a in [("well", well), ("ill", ill)]:
eta_max = 2.0 / a.max()
stability_info.append((name, a, eta_max))ConsoleOut [4]Expand
well: curvature=[2. 1.], kappa=2, stability limit eta<1.000 ill: curvature=[100. 1.], kappa=100, stability limit eta<0.020
PythonIn [5]Expand
# Use one common learning rate that is stable for the ill-conditioned problem,
# and observe how the two curvature vectors respond at that absolute rate.
eta = 1.0 / 100.0 # equals 1 / lambda_max of the ill-conditioned case
steps = 200
path_well, hist_well = run_gd(theta0, well, eta, steps)
path_ill, hist_ill = run_gd(theta0, ill, eta, steps)ConsoleOut [6]Expand
After 200 steps at eta=0.01: well-conditioned J = 9.285e-03 ||theta|| = 1.351e-01 ill-conditioned J = 8.975e-03 ||theta|| = 1.340e-01
At this common step size, the final objectives are about for the well-conditioned problem and for the ill-conditioned problem. Their parameter norms are about and , respectively. The ill-conditioned run is slightly closer in this experiment because its steep coordinate reaches zero after one update, while both shallow coordinates end at . These results do not show faster convergence for the well-conditioned run. Here the objective is , with:
- : the two parameters
- : the curvatures along the two axes
- : the learning rate
- : the condition number
- : the objective value at the final iterate
- : the Euclidean norm of the final parameter vector, which shrinks toward zero as the iterates approach the minimum
Each coordinate contracts by per iteration. At , the well-conditioned factors are and , while the ill-conditioned factors are and . The shallow coordinate therefore moves identically in both runs; the difference in their final objectives comes from the small residual steep coordinate in the well-conditioned run. To assess the advantage available from conditioning, we would also need to compare appropriate step choices for each problem.
PythonIn [7]Expand
# Prepare contour data once; the following cells only present these values.
plot_axis = np.linspace(-1.1, 1.1, 300)
contour_x, contour_y = np.meshgrid(plot_axis, plot_axis)
contour_well = 0.5 * (well[0] * contour_x**2 + well[1] * contour_y**2)
contour_ill = 0.5 * (ill[0] * contour_x**2 + ill[1] * contour_y**2)
levels_well = np.geomspace(0.001, 1.5, 12)
levels_ill = np.geomspace(0.001, 50.5, 14)VisualizationOut [8]Expand

VisualizationOut [9]Expand

The second path reaches the vertical axis after one update. Both runs then have the same shallow-coordinate contraction, so different contour shapes alone do not establish a convergence-rate advantage at this common conservative learning rate.
VisualizationOut [10]Expand

The common rate deliberately gives both shallow coordinates the same multiplier. To see the convergence available to each problem, we now apply the same curvature-based rule, , separately. This gives rates 0.5 and 0.01, respectively, while keeping the starting point and update budget fixed. The change in rate is explicit: we are comparing one common rate rule, rather than one common absolute rate.
PythonIn [11]Expand
rate_well = 1.0 / well.max()
rate_ill = 1.0 / ill.max()
path_well_rule, hist_well_rule = run_gd(theta0, well, rate_well, steps)
path_ill_rule, hist_ill_rule = run_gd(theta0, ill, rate_ill, steps)
assert np.linalg.norm(path_well_rule[-1]) < np.linalg.norm(path_ill_rule[-1])ConsoleOut [12]Expand
Rule eta=1/lambda_max, same start and 200 updates: kappa=2: eta=0.500, objective=1.936e-121, norm=6.223e-61 kappa=100: eta=0.010, objective=8.975e-03, norm=1.340e-01
With this rule the well-conditioned shallow coordinate has multiplier 0.5, while the ill-conditioned one still has multiplier 0.99. This is a conditioning-dependent rate difference on the stated quadratics. Newton uses the full curvature matrix instead: since the Hessian is constant and positive definite, one full step reaches the minimizer in exact arithmetic.
PythonIn [13]Expand
def newton_step(theta, a):
"""One Newton step using the exact diagonal Hessian."""
H = np.diag(a)
return theta - np.linalg.solve(H, gradient(theta, a))
theta_n = newton_step(theta0, ill)ConsoleOut [14]Expand
Newton from [1. 1.] on the ill-conditioned problem: one step -> theta = [0. 0.] objective = 0.000e+00 gradient norm = 0.000e+00
The displayed Newton solution has zero objective and gradient to the reported numerical precision. The code solves a linear system with the exact diagonal Hessian. This example confirms the one-step algebra for this positive-definite quadratic; it does not establish one-step convergence for arbitrary objectives or immunity to floating-point conditioning.
Example
We can check the recurrence by hand, then compare its predicted iterates with the update loop.
Take the well-conditioned curvature , start at , and use . The gradient at the start is , so the first update is
The objective falls from to , where and are the parameters before and after the first update, each term is with the curvature and the parameter along coordinate , and the intermediate sums make the arithmetic explicit. Continuing, we can factor the update per coordinate. For coordinate one the multiplier is ; for coordinate two it is , where is the learning rate and are the curvatures along the two coordinates. So after steps
where and are the two coordinates at iteration . With this starting point and rate, coordinate two decays more slowly. After ten steps it is , while coordinate one is . Here the smaller-curvature direction has the larger contraction multiplier. For another rate, we must compare both absolute multipliers rather than assuming that the smallest curvature always controls the error.
Now verify the Newton prediction. The Hessian is constant, so the step from is
since and the gradient is , where:
- : the Hessian with diagonal entries equal to the curvatures and
- : the inverse of the Hessian, here
- : the gradient at the starting point, here
- : the Newton step
Adding gives , the exact minimum, in a single step. Let us confirm these hand calculations with code so nothing rests on arithmetic we did in our heads.
PythonIn [15]Expand
a = np.array([2.0, 1.0])
theta = np.array([1.0, 1.0])
eta = 0.25
first_three = [theta.copy()]
for k in range(3):
grad = a * theta
theta = theta - eta * grad
first_three.append(theta.copy())
# Closed form prediction from the per-coordinate contraction factors
k = 10
pred = np.array([(1 - eta * a[0]) ** k, (1 - eta * a[1]) ** k])
gd_final = run_gd(np.array([1.0, 1.0]), a, eta, k)[0][-1]
assert np.allclose(gd_final, pred, rtol=1e-12, atol=1e-15)
assert np.allclose(first_three[1], [0.5, 0.75])
assert np.isclose(objective(first_three[1], a), 0.53125)ConsoleOut [16]Expand
After 10 steps the closed form gives theta = [0.00097656 0.05631351] direct run gives theta = [0.00097656 0.05631351] Objective there: 1.587e-03 Newton one-step solution: [0. 0.] iterate k=0: theta=[1. 1.], J=1.50000 iterate k=1: theta=[0.5 0.75], J=0.53125 iterate k=2: theta=[0.25 0.5625], J=0.22070 iterate k=3: theta=[0.125 0.42188], J=0.10461
The example also exposes a subtlety about learning rates. With both coordinates contract, and the objective decreases monotonically. Raise to and coordinate one has multiplier , so it oscillates in sign while shrinking; the objective still decreases each step but the path zig-zags. The multiplier for coordinate is , with the learning rate and the curvature along that coordinate. Raise to and the multiplier for coordinate one is , magnitude greater than one, and the run diverges even though coordinate two still converges. The next cell confirms the threshold numerically.
PythonIn [17]Expand
eta_tests = [0.25, 0.75, 1.25]
eta_results = []
for eta_test in eta_tests:
p, h = run_gd(np.array([1.0, 1.0]), a, eta_test, 12)
eta_results.append((eta_test, h[-1], p[-1]))ConsoleOut [18]Expand
eta=0.25: J after 12 steps = 5.018e-04, final theta = [0. 0.032] eta=0.75: J after 12 steps = 5.960e-08, final theta = [0. 0.] eta=1.25: J after 12 steps = 1.683e+04, final theta = [129.746 0. ] Stability requires eta < 2/2.0 = 1.0 for this problem.
The run with diverges because of the steep direction. On a positive-definite quadratic, an unbounded parameter eventually forces an unbounded objective. Over a finite budget, a lightly weighted coordinate can still look small in the objective while its parameter error matters. Inspect the parameter trajectory alongside the loss when that distinction is relevant.
Implementation in NumPy
We now implement least-squares fitting with four synthetic features and an explicit intercept. The design matrix contains a leading column of ones, so the parameter vector has five entries: the intercept followed by four slopes. Including that intercept is essential to this comparison: centering the features can then be absorbed into the intercept without changing the family of predictions. We compare gradient descent with NumPy's least-squares implementation, using the same design matrix and objective.
PythonIn [19]Expand
import numpy as np
rng = np.random.default_rng(7)
def make_regression(n=200, noise=0.5, seed=0):
"""Four synthetic features and an explicit, unscaled intercept column."""
rng = np.random.default_rng(seed)
features = rng.normal(size=(n, 4))
slopes = np.array([2.0, -1.0, 0.5, 0.0])
y = features @ slopes + noise * rng.normal(size=n)
X = np.column_stack([np.ones(n), features])
beta = np.concatenate([[0.0], slopes])
return X, y, beta
X_raw, y, beta_true = make_regression()
def sse_objective(theta, X, y):
"""Half the mean squared error for this linear design."""
r = y - X @ theta
return 0.5 * np.mean(r**2)
def sse_gradient(theta, X, y):
"""Analytic gradient of half the mean squared error."""
r = y - X @ theta
return -X.T @ r / len(y)
def full_batch_gd(X, y, eta=0.1, steps=200, tol=1e-10):
"""Full gradients; count gradient checks and reject nonfinite iterates."""
theta = np.zeros(X.shape[1])
hist = [sse_objective(theta, X, y)]
gradient_checks = 0
for _ in range(steps):
g = sse_gradient(theta, X, y)
gradient_checks += 1
if not np.all(np.isfinite(g)):
raise FloatingPointError(
"Nonfinite gradient; inspect the learning rate."
)
if np.linalg.norm(g) < tol:
break
candidate = theta - eta * g
with np.errstate(over="ignore", invalid="ignore"):
value = sse_objective(candidate, X, y)
if not np.all(np.isfinite(candidate)) or not np.isfinite(value):
raise FloatingPointError(
"Nonfinite iterate; reduce the learning rate."
)
theta = candidate
hist.append(value)
return theta, np.array(hist), gradient_checksConsoleOut [20]Expand
design matrix: 200 rows, 5 columns feature stds (intercept excluded): [0.973 0.946 1.027 1.042] true coefficients: [ 0. 2. -1. 0.5 0. ] initial objective: 2.5285
The objective is half the mean squared error, , where is the row count, contains the targets, and contains the predictions. Differentiating gives ; the factor of two from the square cancels the one-half in the objective. Duplicating every row leaves this average and its gradient unchanged, although changing the data distribution can still change the curvature and a suitable learning rate. The corresponding sum of squared errors is .
Before interpreting a fit, compare the analytic gradient with a finite difference. This is the numerical check introduced in the formula section; it can expose sign errors or missing factors without relying on the eventual training loss.
PythonIn [21]Expand
def finite_difference_gradient(f, theta, h=1e-6):
"""Central difference estimate, one coordinate at a time."""
theta = np.asarray(theta, dtype=float)
grad = np.zeros_like(theta)
for j in range(len(theta)):
e = np.zeros_like(theta)
e[j] = h
grad[j] = (f(theta + e) - f(theta - e)) / (2 * h)
return grad
theta_test = np.random.default_rng(11).normal(size=X_raw.shape[1])
g_analytic = sse_gradient(theta_test, X_raw, y)
g_numeric = finite_difference_gradient(
lambda t: sse_objective(t, X_raw, y), theta_test
)
max_rel_err = np.max(
np.abs(g_analytic - g_numeric) / np.maximum(1e-8, np.abs(g_numeric))
)
assert max_rel_err < 1e-5ConsoleOut [22]Expand
analytic gradient: [ 0.192819 -0.607492 1.941206 -1.096276 -0.270025] numeric gradient: [ 0.192819 -0.607492 1.941206 -1.096276 -0.270025] Gradient check passed with max relative error 1.86e-09
With the gradient check passed, we fit the model and compare with np.linalg.lstsq, an established least-squares solver. This comparison checks the fitted coefficients and objective for this full-rank design.
PythonIn [23]Expand
# Closed-form reference
theta_ols = np.linalg.lstsq(X_raw, y, rcond=None)[0]
theta_gd, hist_gd, used = full_batch_gd(X_raw, y, eta=0.1, steps=2000)
coef_diff = np.max(np.abs(theta_ols - theta_gd))ConsoleOut [24]Expand
parameter updates: 252; full-gradient checks: 253 stopping reason: gradient tolerance returned gradient norm: 9.357e-11 OLS: [-0.06483 1.96236 -0.99078 0.51876 -0.00477] GD: [-0.06483 1.96236 -0.99078 0.51876 -0.00477] max abs difference: 7.83e-11 objective OLS 0.09391853 vs GD 0.09391853
The four feature columns are approximately standard normal, and the leading intercept column remains one. We multiply the first feature by 200 to change its units while leaving the available predictions unchanged: its slope can be divided by 200. A fixed learning rate of 0.1 becomes unstable on this design. We show only six such updates to keep the demonstration finite, then use for a longer stable run on the same badly scaled problem.
PythonIn [25]Expand
X_bad = X_raw.copy()
X_bad[:, 1] = X_bad[:, 1] * 200.0
# Condition number of X^T X is the square of the design matrix condition number.
cond_raw = np.linalg.cond(X_raw)
cond_bad = np.linalg.cond(X_bad)
hessian_bad = X_bad.T @ X_bad / len(X_bad)
lambda_max_bad = np.linalg.eigvalsh(hessian_bad)[-1]
unsafe_eta = 0.1
theta_unsafe, hist_unsafe, checks_unsafe = full_batch_gd(
X_bad, y, eta=unsafe_eta, steps=6
)
eta_bad = 1.0 / lambda_max_bad
theta_bad, hist_bad, used_bad = full_batch_gd(X_bad, y, eta=eta_bad, steps=2000)
assert unsafe_eta > 2.0 / lambda_max_bad
assert hist_unsafe[-1] > hist_unsafe[0]
assert np.all(np.isfinite(hist_bad))ConsoleOut [26]Expand
cond(X_raw) = 1.2, cond(X^T X) = 1.4 cond(X_bad) = 207.1, cond(X^T X) = 42870.3 Well-scaled objective: 0.093919; gradient checks: 253 Unsafe eta=0.1: objective after 6 updates = 1.612e+43 Bad-design stability ceiling: 5.281e-05 unsafe stopping reason: deliberate six-update budget unsafe returned gradient norm: 1.105e+24 Stable eta=2.641e-05: objective = 0.616614; gradient checks: 2000 safe badly scaled stopping reason: update budget safe badly scaled returned gradient norm: 9.840e-01
The six large-step updates increase the objective sharply because 0.1 exceeds this design's stability ceiling. The second badly scaled run remains finite at , but makes slow progress in the shallow directions. Standardizing the four feature columns lets us use a much larger stable step. We leave the intercept column unchanged because its standard deviation is zero.
PythonIn [27]Expand
mu = X_bad[:, 1:].mean(axis=0)
sd = X_bad[:, 1:].std(axis=0)
assert np.all(sd > 0)
X_scaled = np.column_stack([np.ones(len(X_bad)), (X_bad[:, 1:] - mu) / sd])
theta_scaled, hist_scaled, used_scaled = full_batch_gd(
X_scaled, y, eta=0.1, steps=2000
)
cond_scaled = np.linalg.cond(X_scaled)
theta_scaled_in_bad_units = np.concatenate(
[
[theta_scaled[0] - mu @ (theta_scaled[1:] / sd)],
theta_scaled[1:] / sd,
]
)
scaled_reference = np.linalg.lstsq(X_scaled, y, rcond=None)[0]
assert np.allclose(
X_bad @ theta_scaled_in_bad_units, X_scaled @ theta_scaled, atol=1e-10
)
assert np.allclose(X_raw @ theta_ols, X_scaled @ scaled_reference, atol=1e-10)
assert np.isclose(
hist_scaled[-1], sse_objective(theta_ols, X_raw, y), atol=1e-10
)ConsoleOut [28]Expand
Standardized: cond(X)=1.1, J=0.093919, gradient checks=234 standardized stopping reason: gradient tolerance standardized returned gradient norm: 9.087e-11 Badly scaled, stable step: cond(X)=207.1, J=0.616614, gradient checks=2000 Same OLS optimum: 0.093919 Prediction-map max error: 8.88e-16
With an explicit intercept, standardization preserves the set of possible linear predictions. To map standardized slopes back, divide each by its feature standard deviation; subtract the mean-weighted slopes from the standardized intercept. The assertions above verify that this mapping preserves predictions and that the two reference fits attain the same least-squares optimum. The finite-budget badly scaled gradient-descent run can still be farther from that optimum. All 200 rows here are a synthetic training set; in an evaluated model, estimate scaling statistics on its training split and reuse them unchanged for validation and test data.
Learning rate from the Hessian spectrum
For this full-rank quadratic, the Hessian spectrum gives the stable interval . With our half-mean-squared-error objective the Hessian is , where is the design and is its largest eigenvalue. The next cell computes the eigenvalues and prints , where is the smallest eigenvalue. This rate minimizes the worst-case Euclidean parameter-error contraction for a fixed step on this quadratic.
The update budget trades computation against closeness to the optimizer. Stopping early can also change a model's generalization behavior; its relation to regularization depends on the model and update rule. Tolerances need to match the units and scale of the parameterization and objective. A small absolute gradient threshold is an algorithmic condition, not a universal accuracy measure.
PythonIn [29]Expand
def hessian_spectrum(X):
"""Eigenvalues of the least-squares Hessian X^T X / n."""
H = X.T @ X / len(X)
return np.sort(np.linalg.eigvalsh(H))
ev_scaled = hessian_spectrum(X_scaled)
ev_bad = hessian_spectrum(X_bad)
eta_opt_scaled = 2.0 / (ev_scaled[0] + ev_scaled[-1])
eta_opt_bad = 2.0 / (ev_bad[0] + ev_bad[-1])ConsoleOut [30]Expand
scaled eigenvalues: [0.9371 0.9771 1. 1.0073 1.0785] kappa 1.15, eta_max 1.8545, eta_opt 0.9923 unscaled eigenvalues: [8.800000e-01 8.900000e-01 1.090000e+00 1.190000e+00 3.787083e+04] kappa 42870.3, eta_max 0.000053, eta_opt 0.000053
The badly scaled problem has both a small stable-rate ceiling and a large eigenvalue ratio. At the optimal fixed rate, the worst-case parameter-error contraction is , where . Its factor is therefore much closer to one than the standardized problem's factor. This compares worst-case relative error across starting directions; a particular run also depends on which eigendirections are present in its initial error.
The eigenvalues make the conditioning gap visible. The standardized design has a spectrum clustered near one, while the badly scaled design spans several orders of magnitude. That spread worsens the best worst-case fixed-rate contraction on this quadratic.
VisualizationOut [31]Expand

Stochastic and mini-batch methods
The next implementation reshuffles the training rows each epoch and updates using the mean gradient of the current batch. We use a nonempty design, nonnegative integer epoch budgets, positive integer batch sizes, a positive initial rate, and nonnegative decay. We count per-example gradient contributions used for updates, and separately note that objective evaluation and diagnostic checks also cost work. A final short mini-batch is divided by its actual number of rows.
PythonIn [32]Expand
def sgd(X, y, eta0=0.05, epochs=30, decay=None, seed=0, batch_size=None):
"""SGD with optional mini-batch and learning-rate decay.
Returns parameters, one objective per epoch, and example contributions.
"""
rng = np.random.default_rng(seed)
n, p = X.shape
theta = np.zeros(p)
b = 1 if batch_size is None else batch_size
hist, evals = [], 0
for epoch in range(epochs):
order = rng.permutation(n)
for start in range(0, n, b):
idx = order[start : start + b]
eta = eta0 if decay is None else eta0 / (1 + decay * evals / n)
g = -X[idx].T @ (y[idx] - X[idx] @ theta) / len(idx)
theta = theta - eta * g
evals += len(idx)
hist.append(sse_objective(theta, X, y))
return theta, np.array(hist), evals
theta_sgd, hist_sgd, evals_sgd = sgd(
X_scaled, y, eta0=0.1, epochs=40, decay=1.0, batch_size=1, seed=3
)
theta_mb, hist_mb, evals_mb = sgd(
X_scaled, y, eta0=0.1, epochs=40, decay=1.0, batch_size=32, seed=3
)
full_batch_evals = len(X_scaled) * (len(hist_scaled) - 1)ConsoleOut [33]Expand
SGD : J = 0.093933, update contributions = 8000 mini-batch: J = 0.104950, update contributions = 8000 full batch: J = 0.093919, update contributions = 46600 SGD and mini-batch stopping reason: completed the 40-epoch budget SGD returned full-gradient norm: 5.436e-03 mini-batch returned full-gradient norm: 1.485e-01
These are results for the stated data, learning rates, sampling seed, and finite budgets. They do not prove that one variant always reaches a target objective with fewer operations. The decay rule changes the step according to the number of examples already used for updates; it is a choice for this illustration, not a general convergence certificate.
The horizontal axis below counts per-example gradient contributions in parameter updates. For full-batch descent it increases by per update; the stochastic curves are recorded after each epoch of contributions. This omits the extra cost of evaluating full objectives, terminal gradient checks, and implementation overhead, so it is not a wall-clock comparison.
PythonIn [34]Expand
eval_sgd = np.arange(1, len(hist_sgd) + 1) * len(X_scaled)
eval_mb = np.arange(1, len(hist_mb) + 1) * len(X_scaled)
eval_full = np.arange(len(hist_scaled)) * len(X_scaled)VisualizationOut [35]Expand

Optional: Newton on least squares
On the full-rank least-squares design, the Hessian is constant and positive definite. We can therefore demonstrate the full Newton step directly. The implementation below is specific to this quadratic and does not add a line search or a logistic-loss example. General nonquadratic objectives need the safeguards discussed above.
PythonIn [36]Expand
def newton_ls(X, y, steps=5):
"""Newton on full-rank least squares, with one history entry per update."""
H = X.T @ X / len(X)
theta = np.zeros(X.shape[1])
hist = [sse_objective(theta, X, y)]
for _ in range(steps):
g = sse_gradient(theta, X, y)
if np.linalg.norm(g) < 1e-10:
break
theta = theta - np.linalg.solve(H, g)
hist.append(sse_objective(theta, X, y))
return theta, np.array(hist)
theta_newton, hist_newton = newton_ls(X_bad, y, steps=5)ConsoleOut [37]Expand
Newton on ill-conditioned LS: J = 0.0939185272, steps used 1 Newton objective history: [2.52846336 0.09391853] Newton solution: [-0.065 0.01 -0.991 0.519 -0.005] Gradient norm at the solution: 3.03e-14 Newton stopping reason: gradient tolerance coefficients match OLS: True
The printed gradient norm and reference comparison quantify the numerical error. One update reaches the least-squares solution to the reported precision on this design. The exact-arithmetic result is independent of the condition number, while the accuracy of a floating-point linear solve can still depend on scaling and conditioning.
Key Parameters
Below are the main parameters that affect how gradient-based optimization behaves, with guidance on how to select them.
etascales the negative gradient. For the positive-definite quadratics here, convergence from every start requires , and minimizes the worst-case fixed-rate parameter contraction. General objectives need an appropriate step-selection rule.stepscaps the update count. More iterations can reduce optimization error in a stable run; they do not repair an unstable rate. Early stopping can regularize some fitting procedures, but the optimization examples here do not evaluate that effect on held-out data.tolis the absolute gradient-norm threshold used by this illustrative implementation. Interpret it in the current objective and parameter units, and report the returned residual and budget.batch_sizeis the number of examples used per update. It changes gradient noise, work per update, and updates per epoch; 32 is the setting demonstrated here.decaycontrols the displayed schedule , where counts per-example gradient contributions already used. The code uses this example-count clock, which differs from counting parameter updates.
Before increasing the budget or loosening a tolerance, check the gradient calculation and the response to the learning rate. A decay schedule and a batch size should be assessed together under a stated budget and sampling rule.
Practical Implications
Gradient descent is useful when a differentiable objective is available and first-order updates suit the data and parameter dimensions. A dense linear-model gradient can be cheaper to compute and store than its Hessian. A smooth penalty can be differentiated along with the loss; a nonsmooth penalty or a constraint needs an update rule that handles that additional structure.
Curvature-aware methods are also worth considering when their matrix operations are affordable. Dense Newton steps and quasi-Newton approximations can reduce the number of updates on some problems, but iteration count alone does not measure total cost. Their behavior depends on the objective, derivatives, safeguards and starting point.
On a nonconvex problem, report the objective, gradient norm, stopping reason and initialization. These diagnostics help describe the returned point, but they do not generally prove that it is a local or global minimum. A budget-limited or divergent run must be distinguished from a small-residual termination.
Best Practices
For the positive-definite quadratic examples, the spectrum gives an explicit stable interval and the optimal worst-case fixed rate. For a general objective, local curvature may change as the parameters move. A line search or another step-selection rule can assess proposed displacements; a short successful trial alone does not prove that one fixed rate will remain suitable throughout the run.
When gradient noise persists near an optimum, smaller or decaying steps can reduce fluctuations. Constant steps can still be appropriate, and some problems have vanishing gradient noise at the solution. Inspect trajectories over a stated budget and consider repeated sampling seeds; a flat noisy curve alone is not a convergence proof.
Check the analytic gradient at several representative parameter values against a numerical approximation. A discrepancy can expose a sign, normalization or indexing error, although a finite set of checks is not a proof. During fitting, record enough information to distinguish small gradients, tiny steps, numerical failure and an exhausted budget.
Data Requirements and Preprocessing
For unpenalized linear least squares with an intercept, invertible feature scaling and centering preserve the prediction family after the corresponding coefficient transformation. They can improve conditioning when unequal feature scales are the cause. They do not remove exact dependence between columns, and equal variances can coexist with strong correlations and a large condition number. A suitably chosen gradient-descent step can converge without standardization.
Estimate preprocessing statistics on the training portion used for fitting and apply the fitted transformation to held-out data. If a penalty is defined in coefficient units, decide how it should transform when those units change: applying the same coefficient penalty after rescaling generally changes the fitting objective. Inspect unusual values and feature scales in the context of the data rather than treating scaling as a substitute for model checks.
A full gradient can be accumulated across smaller data blocks before taking one update, so full-batch descent does not require the entire dataset to remain in memory. Stochastic methods update from individual examples or subsets. Fresh independent sampling and random reshuffling are different procedures; the fixed-parameter unbiasedness result does not by itself cover the adaptive gradients produced during a reshuffled epoch.
Common Pitfalls
A growing or oscillating objective on a fixed quadratic can indicate an unsuitable step. Other causes include derivative mistakes and nonfinite arithmetic. The examples show an explicit instability ceiling, but observations on a general objective need to be interpreted with its changing curvature and update rule.
A sign reversal changes a descent update into an ascent update whenever the gradient is nonzero. Omitting a positive scalar factor can instead act like changing the learning rate; other implementation errors can change the direction or even make the array operations invalid. Numerical checks should test the actual derivative and normalization used by the loop.
Stagnation can arise from a tiny step, finite precision, poor conditioning, or approach to a stationary point. A small parameter change and a small objective change do not distinguish these cases on their own. The gradient residual and stated assumptions provide additional information; even all three diagnostics together do not generally certify a global optimum.
Computational Considerations
For the dense linear model, a full-gradient update uses work, a single-example update , and a mini-batch update . These costs exclude full-objective monitoring and depend on the model's per-example derivative cost. Independent batch averaging reduces covariance by at fixed parameters; the statistical and hardware trade-offs should be assessed for the actual sampling scheme and implementation.
The examples store the full design for simplicity. A first-order implementation can accumulate a full gradient or process updates from a batch while retaining the parameter vector. A dense least-squares Hessian occupies storage, takes work to form directly, and typically takes work to factor; these are costs of the dense formulation rather than all Newton-type methods.
A solver such as scipy.optimize.minimize exposes the BFGS quasi-Newton method for smooth objectives. Compare such a solver with first-order alternatives using the achieved residual, objective, work and memory requirements, rather than assuming a dimension cutoff gives one method a universal advantage. For this chapter's least-squares example, np.linalg.lstsq provides the reference fit.
Performance and Deployment Considerations
The training objective measures fitting on the training data. A lower value does not guarantee better held-out performance. Keep optimization diagnostics separate from an evaluation of the fitted model on data and a metric chosen for the application.
Report the criterion that stopped the run, its tolerances, the final objective and gradient norm, and the available budget. Relative objective and parameter changes can supplement the gradient residual. If termination occurred because improvement was small, say so; do not silently treat that condition as proof that the minimizer was reached.
Repeated seeds can show how much a stochastic run varies under its sampling procedure. Logs can also reveal changes in the observed trajectory, but they do not identify a unique cause such as data drift or an incorrect step size. Investigate the data, derivatives and update rule together when behavior changes.
Summary
Gradient descent uses the gradient to construct a local update. On a positive-definite quadratic, eigenvalue analysis determines the stable interval and the best worst-case fixed-rate contraction. The scalar recurrence makes convergence, sign alternation and divergence explicit. These exact statements require the stated quadratic conditions; a general nonconvex objective needs additional analysis.
The examples also show why the comparison setup matters. One common absolute rate gave the two quadratics identical shallow-coordinate progress; applying the same reciprocal-curvature rate rule separately exposed a much faster well-conditioned run. In least squares, keeping an explicit intercept made standardized and unscaled reference fits equivalent, while their finite-budget gradient-descent progress differed. Newton reached the quadratic solution to the reported numerical precision with one update.
Batch and stochastic variants use different amounts of data per update and require explicit sampling and work-count conventions. A numerical gradient check can catch implementation mistakes, and stopping diagnostics describe what a run achieved. Curvature, feature coordinates, numerical arithmetic and gradient noise all belong in that interpretation; a termination message alone is not a certificate of global optimality.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about gradient descent, curvature, learning-rate stability, and numerical optimization checks.
Gradient Descent and Numerical Optimization
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!