8.11. Gradient Descent with Momentum#
8.11.1. Momentum#
Momentum is an adjustment to the vanilla gradient descent method that serves two purposes. First, it can speed up convergence when the optimizer becomes trapped in a region of large curvature that runs transverse to the optimization direction. I like to imagine this as a riverbed valley: the loss function flows slowly downstream toward the minimum, while the steep valley walls on either side represent regions of large gradient. The optimizer, caught between these walls, bounces back and forth across the valley when it should be moving downstream.
Second, momentum helps the optimizer push through shallow local minima, allowing it to continue toward a lower region of the loss surface.
As a physicist, I have to say, I don’t think gradient descent with momentum has much to do with actual momentum.
I’ll now try to give an intuitive understanding of how the momentum method works; this is my own way of explaining it, hopefully it helps.
To understand momentum, we first need to recognize that the gradient carries directional information.
Consider the function
This function is quadratic in both \(x\) and \(y\), but the curvature in the \(y\) direction is much greater than in the \(x\) direction. The gradient is
Evaluating the gradient at the point \((4, 1)\) gives
showing that the gradient is larger in the \(y\) direction than in the \(x\) direction at this point.
The momentum method uses past gradient evaluations to reduce motion in unwanted directions and accelerate motion in desired ones.
Let’s return to the river valley analogy. The valley walls are steep and therefore have large gradients. The minimum lies downstream, where the river flows slowly, meaning the gradients in that direction are small. Because the optimizer responds strongly to large gradients, it tends to oscillate back and forth between the valley walls instead of moving smoothly downstream. That oscillation is wasted optimization effort—we want to move downstream, not sideways.
To fix this, suppose we combine the gradients from step \(n\) and step \(n-1\). Let’s call the river-valley loss function \(\Phi\). Then, schematically,
If you follow that reasoning, you can see why combining gradients from previous steps damps motion across the valley walls and enhances progress downstream—exactly what we want from momentum.
The momentum method combines the current gradient with a fraction of the accumulated past gradients. Using the notation from the example code (below), the momentum updates can be written as
where \(b_i\) is the momentum term, \(\mu\) is the momentum coefficient (typically \(0 < \mu < 1\)), \(\gamma\) is the learning rate, and \(\nabla \Phi(x_{i-1})\) is the gradient of the loss function at the previous step.
The initial conditions are
At each step, the momentum term \(b_i\) accumulates past gradients with exponential decay, dampening oscillations across steep directions and accelerating movement along consistent gradient directions. When \(\mu = 0\), this reduces to standard gradient descent.
The momentum term \(b_i\) combines current and past gradients. Again, the momentum algorithm is
where \(0 < \mu < 1\) is the momentum coefficient and \(\nabla \Phi(x_{i-1})\) is the gradient at the previous step.
Unrolling this recursion gives,
If we initialize \(b_0 = 0\), this reduces to
This shows that the influence of each past gradient decreases geometrically by a factor of \(\mu\) at each step. I think that I hear people say that this is exponential decay, but I think this is geometric decay (I am usually wrong though.) In anycase, it is clear that the influence of past gradients is decreasing with each step.
You can read about gradient descent with momentum here. That website is where I got the one-dimensional example from.
import jax
import jax.numpy as jnp
import numpy as np
from jax import grad, jacobian, hessian
%matplotlib inline
import matplotlib.pyplot as plt
from matplotlib import animation
from IPython.display import HTML
---------------------------------------------------------------------------
ModuleNotFoundError Traceback (most recent call last)
Cell In[1], line 1
----> 1 import jax
2 import jax.numpy as jnp
3 import numpy as np
ModuleNotFoundError: No module named 'jax'
One-Dimensional Example
We now compare vanilla gradient descent to momentum-based gradient descent for a one-dimensional problem. This problem contains that persistent optimization goblin we all fear: the local minimum (cue scary sound effects). Note: the only way to get really good at coding is to add sound effects while you code—experimentally verified.
The objective function for this example is
def objective(x):
# Slight variation on Cornell's original function
return 0.5*x**4 - 0.005*x**3 - 3*x**2 - 2*x
# Define the objective function
def objective(x):
# Slight variation on Cornell's original function
# Original: 0.3*x**4 - 0.1*x**3 - 2*x**2 - 0.8*x
return 0.5*x**4 - 0.005*x**3 - 3*x**2 - 2*x
# Sample points
xmin=-3
xmax=3
nx=1000
x=jnp.linspace(xmin, xmax, nx)
Phi = objective(x)
# Plot the objective function
fig, ax = plt.subplots()
ax.plot(x, Phi)
ax.set_xlabel("x", size=20)
ax.set_ylabel("Objective function", size=20)
ax.grid(True)
plt.show()
import jax
import jax.numpy as jnp
import numpy as np
from jax import grad
def gradient_descent(Phi, x0, n_iter, alpha, tol):
"""
Vanilla gradient descent using JAX.
Parameters
----------
Phi : function
Objective function to minimize.
x0 : float
Initial guess.
n_iter : int
Maximum number of iterations.
alpha : float
Learning rate.
tol : float
Tolerance for convergence.
Returns
-------
x_data : np.ndarray
Array of iterates.
Phi(x_data[-1]) : float
Objective function value at final iterate.
"""
# Initialize storage for iterates
x_data = np.full(n_iter, np.nan)
x_data[0] = x0
# JAX gradient
dPhi = grad(Phi)
for i in range(1, n_iter):
g_i = dPhi(x0) # compute gradient
x = x0 - alpha * g_i # update step
x_data[i] = x
if jnp.abs(x - x0) <= tol:
print("Converged to", x)
break
x0 = x # update current position
# Remove unused entries
x_data = x_data[~np.isnan(x_data)]
return x_data, Phi(x_data[-1])
x0=-3.0
x1, sol1=gradient_descent(objective, x0, 200, .007, 1e-4)
Converged to -1.5293782
import jax
import jax.numpy as jnp
import numpy as np
from jax import grad
def momentum_descent(Phi, x0, n_iter, mu, gamma, tol):
"""
Gradient descent with momentum using JAX.
Parameters
----------
Phi : function
Objective function to minimize.
x0 : float
Initial guess.
n_iter : int
Maximum number of iterations.
mu : float
Momentum coefficient (0 < mu < 1).
gamma : float
Learning rate.
tol : float
Tolerance for convergence.
Returns
-------
x_data : np.ndarray
Array of iterates.
Phi(x_data[-1]) : float
Objective function value at final iterate.
"""
# Initialize storage for iterates
x_data = np.full(n_iter, np.nan)
x_data[0] = x0
# Initialize momentum term
b_i = 0.0
# JAX gradient
dPhi = grad(Phi)
for i in range(1, n_iter):
g_i = dPhi(x0) # compute gradient
b_i = mu * b_i + g_i # update momentum
x = x0 - gamma * b_i # update position
x_data[i] = x
if jnp.abs(x - x0) <= tol:
print("Converged to", x)
break
x0 = x # update current position
# Remove unused entries
x_data = x_data[~np.isnan(x_data)]
return x_data, Phi(x_data[-1])
x2, sol2= momentum_descent(objective, x0, 200, .7, 0.05, 1e-4)
Converged to 1.8815817
# --- Run gradient descent methods ---
x0 = -3.0
n_iter = 50
alpha = 0.01
mu = 0.9
gamma = 0.01
tol = 1e-6
# x array for plotting the objective function
x = np.linspace(-3, 3, 400)
cost = objective(x)
# Compute iterates
x1, _ = gradient_descent(objective, x0, n_iter, alpha, tol)
x2, _ = momentum_descent(objective, x0, n_iter, mu, gamma, tol)
# Compute objective function values at all iterates
sol1 = objective(x1)
sol2 = objective(x2)
# --- Setup animation ---
n_frames = max(x1.size, x2.size)
n_stop = min(x1.size, x2.size)
fig = plt.figure(figsize=(10, 4))
# Vanilla gradient descent subplot
ax1 = fig.add_subplot(121)
ax1.set_title("Gradient Descent", size=20)
ax1.set_xlabel("x", size=20)
ax1.set_ylabel("Objective Function", size=20)
ax1.plot(x, cost, color='blue')
p1, = ax1.plot([], [], "o", color="red")
ax1.grid(True)
# Momentum gradient descent subplot
ax2 = fig.add_subplot(122)
ax2.set_title("Gradient Descent\nwith Momentum", size=20)
ax2.set_xlabel("x", size=20)
ax2.plot(x, cost, color='blue')
p2, = ax2.plot([], [], "o", color="red")
ax2.grid(True)
plt.tight_layout()
plt.close()
# Initialize animation
def init():
p1.set_data([], [])
p2.set_data([], [])
return p1, p2
# Animation update function
def ani(i):
if i >= n_stop:
if x1.size < x2.size:
p1.set_data([x1[n_stop-1]], [sol1[n_stop-1]])
p2.set_data([x2[i]], [sol2[i]])
else:
p2.set_data([x2[n_stop-1]], [sol2[n_stop-1]])
p1.set_data([x1[i]], [sol1[i]])
else:
p1.set_data([x1[i]], [sol1[i]])
p2.set_data([x2[i]], [sol2[i]])
return p1, p2
# Create the animation
anim = animation.FuncAnimation(fig, ani, frames=n_frames, init_func=init, interval=100, blit=True)
# To display in a Jupyter notebook
from IPython.display import HTML
HTML(anim.to_jshtml())
HTML(anim.to_html5_video())
anim.save("1D-momentum_gradient_descent.mp4")