Skip to article content

Neural Networks

Back to Article
Continuous Optimization
Download Notebook

Continuous Optimization

Reading: Chapter 7: MML Book

Recall geometry of a derivative

Minimizing general functions

We cannot minimize general functions by solving

f(x)x=0\frac{\p f(\bfx)}{\p \bfx} = \mathbf{0}^\top

because the equation might not have a formula for it.

Instead we use iterative methods like gradient descent minimize general function f(x)f(\bfx).

Definition (Directional derivative)

Directional dervivative of a function f(x):RnRf(\bfx) : \bbR^n \to \bbR with respect to a given unit vector u^Rnu^=1\hat{\bfu} \in \bbR^n | \|\hat{\bfu}\| = 1 is defined as

Duf(x)=limϵ0f(x+ϵu^)f(x)ϵD_\bfu f(\bfx) = \lim_{\epsilon \to 0} \frac{f(\bfx + \epsilon \hat{\bfu}) - f(\bfx)}{\epsilon}

Ref Khan Academy

Ref Libretexts

Vector calculus chain rule (a theorem)

Given a function composition f(x)=g(h(x))=(gh)(x)\bff(\bfx) = \bfg(\bfh(\bfx)) = (\bfg \circ \bfh)(\bfx) where h:RnRm\bfh : \bbR^n \to \bbR^m, g:RmRp\bfg : \bbR^m \to \bbR^p and h:RmRp\bfh: \bbR^m \to \bbR^p

fx=fhhx\frac{\p \bff}{\p \bfx} = \frac{\p \bff}{\p \bfh}\frac{\p \bfh}{\p \bfx}

or denoting the derivatives as Jacobian matrices we have,

Jxf=Jh[f]Jx[h]\calJ_\bfx \bff = \calJ_\bfh[\bff] \calJ_\bfx [ \bfh ]
Theorem (Directional derivative is gradient dot product with the direction)

Express the trajectory in the direction u\bfu as a function of time tt as

g(t)=x+tu^\bfg(t) = \bfx + t \hat{\bfu}

Note that the Jacobian of g(t)\bfg(t) wrt tt is simply u\bfu,

Jtg(t)=u^\calJ_t \bfg(t) = \hat{\bfu}

Recall the definition of directional derivative,

Duf(x)=limϵ0f(x+ϵu^)f(x)ϵ.D_\bfu f(\bfx) = \lim_{\epsilon \to 0} \frac{f(\bfx + \epsilon \hat{\bfu}) - f(\bfx)}{\epsilon}.

Compare it with the derivative of f(g(t))f(\bfg(t)) with respect to tt at t=0t=0

f(g(t))t=limϵ0f(x+(t+ϵ)u^)f(x+tu^)ϵt=0.\frac{\p f(\bfg(t))}{\p t} = \lim_{\epsilon \to 0} \frac{f(\bfx + (t + \epsilon) \hat{\bfu}) - f(\bfx + t\hat{\bfu})}{\epsilon} \Big\vert_{t = 0}.

f(g(t))t=limϵ0f(x+ϵu^)f(x)ϵ=Duf(x).\frac{\p f(\bfg(t))}{\p t} = \lim_{\epsilon \to 0} \frac{f(\bfx + \epsilon \hat{\bfu}) - f(\bfx )}{\epsilon} = D_\bfu f(\bfx).

We can compute f(g(t))t\frac{\p f(\bfg(t))}{\p t} by chain rule,

Duf(x)=Jtf(g(t))=Jxf(x)Jtg=xf(x)u^D_\bfu f(\bfx) = \calJ_t f(\bfg(t)) = \calJ_\bfx f(\bfx) \calJ_t \bfg = \nabla_\bfx^\top f(\bfx) \hat{\bfu}

Theorem : The direction of stepest ascent and descent

Let u^\hat{\bfu} be of unit magnitude. The directional derivative represents how the function changes in the direction u^\hat{\bfu}.

Du^f(x)=xf(x)u^=xf(x)cos(θ),D_{\hat{\bfu}} f(\bfx) = \nabla_\bfx^\top f(\bfx) \hat{\bfu} = \|\nabla_\bfx f(\bfx)\|\cos(\theta),

where θ\theta is the angle between xf(x)\nabla_\bfx f(\bfx) and u^\hat{\bfu}. The change is maximum when θ=0\theta = 0 and cos(θ)=1\cos(\theta) = 1 and the change is minimum when θ=180\theta = 180^\circ and cos(θ)=1\cos(\theta) = -1.

In other words the function ff increases the most (stepest ascent) when u^xf(x)\hat{\bfu} \propto \nabla_\bfx f(\bfx) and decreases the most (steepest descent) when u^xf(x)\hat{\bfu} \propto - \nabla_\bfx f(\bfx).

  1. Start from a random point x0\bfx_0, xtx0\bfx_t \leftarrow \bfx_0.

  2. Move in the direction opposite to xf(x)\nabla_\bfx f(\bfx). If we were at xt\bfx_t, then the next point is at xt+1=xtαtxf(x)\bfx_{t+1} = \bfx_t - \alpha_t \nabla_\bfx f(\bfx), where αt>0\alpha_t > 0 is a positive scalar, called the step size or the learning rate.

  3. Stop when the gradient is almost zero xf(x)<104\|\nabla_\bfx f(\bfx)\| < 10^{-4}.

This corresponds to the following algorithm:

xt=x0\bfx_t = \bfx_0
while (xf(x)>104\|\nabla_\bfx f(\bfx)\| > 10^{-4}) { xtxtαtxf(x)\bfx_t \leftarrow \bfx_t - \alpha_t \nabla_\bfx f(\bfx) }

Algorithm 9.3 Gradient descent method.

given a starting point xdomf\bfx \in \text{dom}{f} .

repeat

  1. Δx=f(x)\Delta x = -\nabla f(\bfx).

  2. Choose step size α\alpha

  3. Update. x:=x+αx\bfx := \bfx + \alpha \bfx

until stopping criterion is satisfied.

Gradient visualization

import matplotlib.pyplot as plt
import numpy as np
from matplotlib import animation, rc
rc('animation', html='jshtml')
def plot_gradients(func, gradfunc):
    x, y = np.mgrid[-3:3:21j,
                    -3:3:21j]
    bfx = np.array([x, y])
    f = func(x,y)
    [dfdx, dfdy] = gradfunc(x,y)
    fig, ax = plt.subplots()
    ctr = ax.contour(x, y, f, 20, cmap='Blues_r')
    ax.quiver(x, y, dfdx, dfdy)
    ax.plot([0], [0], 'ro') 
    ax.text(0, 0, '$x^*$', color='r')
    ax.set_xlabel('$x$')
    ax.set_ylabel('$y$')
    plt.show()
    
def f(x, y): return  x**2 + y**2
def gradf(x, y): return 2*x, 2*y
plot_gradients(f, gradf)
<Figure size 640x480 with 1 Axes>

Quadratic function example:

A quadratic function f(x)=x12+x22f(\bfx) = x_1^2 + x_2^2 has level sets as circles:

S(f,c)={x:x12+x22=c}S(f, c) = \{\bfx : x_1^2 + x_2^2 = c\}

It has the parameteric form as

S(f,c)={g(c,θ)=[ccos(θ)csin(θ)]:θ[0,2π)}S(f, c) = \{\bfg(c, \theta) = \begin{bmatrix}\sqrt{c} \cos(\theta) \\ \sqrt{c} \sin(\theta)\end{bmatrix} : \theta \in [0, 2\pi)\}

The gradient is:

xf(x)=2x=2[ccos(θ)csin(θ)]\nabla_\bfx f(\bfx)^\top = 2\bfx^\top = 2 \begin{bmatrix} \sqrt{c} \cos(\theta) & \sqrt{c} \sin(\theta)\end{bmatrix}

and

The derivative of curve with respect to θ\theta the tangent to the curve:

Jθg(c,θ)=[θccos(θ)θcsin(θ)]=[csin(θ)ccos(θ)]\calJ_\theta \bfg(c, \theta) = \begin{bmatrix} \frac{\p }{\p \theta} \sqrt{c} \cos(\theta) \\ \frac{\p }{\p \theta} \sqrt{c} \sin(\theta)\end{bmatrix} = \begin{bmatrix}-\sqrt{c} \sin(\theta) \\ \sqrt{c} \cos(\theta)\end{bmatrix}
xf(x)Jθg(c,θ)=2[ccos(θ)csin(θ)][csin(θ)ccos(θ)]=0\nabla_\bfx f(\bfx)^\top\calJ_\theta \bfg(c, \theta) = 2 \begin{bmatrix} \sqrt{c} \cos(\theta) & \sqrt{c} \sin(\theta)\end{bmatrix} \begin{bmatrix}-\sqrt{c} \sin(\theta) \\ \sqrt{c} \cos(\theta)\end{bmatrix} = 0

Taylor series approximation

A bit more about the step size/learning rate

Taylor series expansion originates from integration by parts

The fundamental theorem of calculus states that

f(x)=f(a)+axf(t)dtf(x)=f(a)+\int _{a}^{x}\,f'(t)\,dt

Now we can integrate by parts and use the fundamental theorem of calculus again to see that

f(x)=f(a)+(xf(x)af(a))axtf(t)dt=f(a)+x(f(a)+axf(t)dt)af(a)axtf(t)dt=f(a)+(xa)f(a)+ax(xt)f(t)dt,\begin{align} f(x)&=f(a)+{\Big (}xf'(x)-af'(a){\Big )}-\int _{a}^{x}tf''(t)\,dt\\&=f(a)+x\left(f'(a)+\int _{a}^{x}f''(t)\,dt\right)-af'(a)-\int _{a}^{x}tf''(t)\,dt\\&=f(a)+(x-a)f'(a)+\int _{a}^{x}\,(x-t)f''(t)\,dt, \end{align}

Recall the Taylor series expansion of a function f(x)f(x) around x0x_0

f(x)=f(x0)+df(x)dx(xx0)+12!d2f(x)dx2(xx0)2++1n!dnf(x)dxn(xx0)n+f(x) = f(x_0) + \frac{d f(x)}{d x} (x - x_0) + \frac{1}{2!} \frac{d^2 f(x)}{d x^2} (x - x_0)^2 + \dots + \frac{1}{n!} \frac{d^n f(x)}{d x^n} (x - x_0)^n + \dots

Vectorized Taylor series expansion is

f(x)=f(x0)+xf(x)(xx0)+12!(xx0)Hf(x)(xx0)+f(\bfx) = f(\bfx_0) + \nabla_\bfx^\top f(\bfx) (\bfx - \bfx_0) + \frac{1}{2!} (\bfx - \bfx_0)^\top \calH f(\bfx) (\bfx - \bfx_0)^\top + \dots \infty

While optimizing around the point xt\bfx_t, we can use the Taylor series expansion to find a local quadratic approximation to find the next best minima:

f^(xt+1)=f(xt)+xf(x)(xt+1xt)+12!(xt+1xt)Hf(x)(xt+1xt)\hat{f}(\bfx_{t+1}) = f(\bfx_t) + \nabla_\bfx^\top f(\bfx) (\bfx_{t+1} - \bfx_t) + \frac{1}{2!} (\bfx_{t+1} - \bfx_t)^\top \calH f(\bfx) (\bfx_{t+1} - \bfx_t)^\top

At the optimal point of the quadratic approximation the derivative is zero:

f^(xt+1)xt+1=0\frac{\p \hat{f}(\bfx_{t+1})}{\p \bfx_{t+1}} = \mathbf{0}^\top

xf(x)+(xt+1xt)Hf(x)=0\nabla_\bfx^\top f(\bfx) + (\bfx_{t+1} - \bfx_t)^\top \calH f(\bfx) = \mathbf{0}^\top

Taking transpose and rearranging the terms we get:

xt+1xt=[Hf(x)]1xf(x)\bfx_{t+1} - \bfx_t = -[\calH f(\bfx)]^{-1}\nabla_\bfx f(\bfx)

xt+1=xt[Hf(x)]1xf(x)\bfx_{t+1} = \bfx_t -[\calH f(\bfx)]^{-1}\nabla_\bfx f(\bfx)

This is the update rule for the Newton’s method for optimization. Note that the step size here is inversely proportional the second derivative.

Taylor series approximation

Approximate the following function to a quadratic function near the point x0=[2,3]\bfx_0 = [-2, 3]

f(x)=0.06exp(2x1+x2)+0.05exp(x12x2)+exp(x1)f (x) = 0.06\exp( 2x_1 +x_2) + 0.05\exp(x_1−2 x_2) + \exp(−x_1)

f(x)=0.06exp([2,1]x)+0.05exp([1,2]x)+exp([1,0]x)f(\bfx) = 0.06 \exp([2, 1]\bfx) + 0.05 \exp([1, -2]\bfx) + \exp([-1, 0]\bfx)

Let b1=[2,1]\bfb_1^\top = [2, 1], b2=[1,2]\bfb_2^\top = [1, -2], b3=[1,0]\bfb_3^\top = [-1, 0].

xf(x)=0.06b1exp(b1x)+0.05b2exp(b2x)+b3exp(b3x)\nabla_\bfx^\top f(\bfx) = 0.06 \bfb_1^\top \exp(\bfb_1^\top\bfx) + 0.05 \bfb_2^\top\exp(\bfb_2^\top\bfx) + \bfb_3^\top\exp(\bfb_3^\top\bfx)
xf(x)=0.06b1exp(b1x)+0.05b2exp(b2x)+b3exp(b3x)\nabla_\bfx f(\bfx) = 0.06 \bfb_1 \exp(\bfb_1^\top\bfx) + 0.05 \bfb_2 \exp(\bfb_2^\top\bfx) + \bfb_3\exp(\bfb_3^\top\bfx)
Hf(x)=x(xf(x))=0.06b1exp(b1x)b1+0.05b2exp(b2x)b2+b3exp(b3x)b3H f(\bfx) = \nabla_\bfx^\top (\nabla_\bfx f(\bfx)) = 0.06 \bfb_1 \exp(\bfb_1^\top\bfx) \bfb_1^\top + 0.05 \bfb_2 \exp(\bfb_2^\top\bfx) \bfb_2^\top + \bfb_3\exp(\bfb_3^\top\bfx)\bfb_3^\top
Hf(x)=0.06exp(b1x)b1b1+0.05exp(b2x)b2b2+exp(b3x)b3b3H f(\bfx) = 0.06 \exp(\bfb_1^\top\bfx) \bfb_1 \bfb_1^\top + 0.05 \exp(\bfb_2^\top\bfx) \bfb_2 \bfb_2^\top + \exp(\bfb_3^\top\bfx)\bfb_3\bfb_3^\top
# Define the function
def f(x):
    
    """
    1. For an input x of shape x.shape = (2,) 
        f(x) must return a scalar
        
    2. For an input x of shape x.shape = (m, 2), 
        f(x) must return an array of shape (m,) 
        which contains f(x) is computed for m values of x

    3. For an input x of shape x.shape = (m, n, 2)
        f(x) must return an array of shape (m, n) 
        which contains f(x) is computed for (m x n) values of x
    """
    return (0.06 * np.exp(x @ [2, 1]) 
            + 0.05* np.exp(x @ [1, -2]) 
            + np.exp(x @ [-1, 0]))

# Compute its derivative, the gradient function
def grad_f(x):     
    """
    1. For an input x of shape x.shape = (2,) 
        grad_f(x) must return a n array of shape (2,)
        
    2. For an input x of shape x.shape = (m, 2), 
        grad_f(x) must return an array of shape (m, 2) 
        which contains grad_f(x) is computed for m values of x

    3. For an input x of shape x.shape = (m, n, 2)
        grad_f(x) must return an array of shape (m, n, 2) 
        which contains grad_f(x) is computed for (m x n) values of x
    """
    coeff1 = np.array([2, 1])
    coeff2 = np.array([1, -2])
    coeff3 = np.array([-1, 0])
    # Slicing using np.newaxis or None, increases the dimension by 1.
    # https://numpy.org/doc/stable/reference/constants.html#numpy.newaxis
    return (0.06 * np.exp(x @ coeff1)[..., None] * coeff1
            + 0.05 * np.exp(x @ coeff2)[..., None] * coeff2
            + np.exp(x @ coeff3)[..., None] * coeff3)

def numerical_jacobian(f, x, h=1e-10):
    n = x.shape[-1]
    eye = np.eye(n)
    x_plus_dx = x + h * eye # n x n
    num_jac = (f(x_plus_dx) - f(x)) / h # limit definition of the formula # n x m
    if num_jac.ndim >= 2:
        num_jac = num_jac.swapaxes(-1, -2) # m x n
    return num_jac
    
# Compare our grad_f with numerical gradient
def check_numerical_jacobian(f, jac_f,  nD=2, **kwargs):
    x = np.random.rand(nD)
    num_jac = numerical_jacobian(f, x, **kwargs)
    return np.allclose(num_jac, jac_f(x), atol=1e-06, rtol=1e-4) # m x n

## Throw error if grad_f is wrong
assert check_numerical_jacobian(f, grad_f)

## Gradient of gradient
def hessian_f(x):
    """
    1. For an input x of shape x.shape = (2,) 
        hessian_f(x) must return a n array of shape (2, 2)
        
    2. For an input x of shape x.shape = (m, 2), 
        hessian_f(x) must return an array of shape (m, 2, 2) 
        which contains hessian_f(x) is computed for m values of x

    3. For an input x of shape x.shape = (m, n, 2)
        hessian_f(x) must return an array of shape (m, n, 2, 2) 
        which contains hessian_f(x) is computed for (m x n) values of x 
    """
    coeff1 = np.array([2, 1])
    coeff2 = np.array([1, -2])
    coeff3 = np.array([-1, 0])
    return (0.06 * np.exp(x @ coeff1)[..., None, None] * np.outer(coeff1, coeff1)
            + 0.05 * np.exp(x @ coeff2)[..., None, None] * np.outer(coeff2, coeff2)
            +  np.exp(x @ coeff3)[..., None, None] * np.outer(coeff3, coeff3))

## Throw error if hessian_f is wrong
assert check_numerical_jacobian(grad_f, hessian_f)


def taylor_series_quad_approx(x0, func, grad_func, hessian_func):
    def quad_func(x):
        x_min_x0 = (x-x0)[..., None] # make column vectors
        x_min_x0_T = (x-x0)[..., None, :] # make row vectors
        grad_f_x0_T = grad_func(x0)[..., None, :] # make row vectors
        return (func(x0) 
                + grad_f_x0_T @ x_min_x0
                + x_min_x0_T @ hessian_func(x0) @ x_min_x0).squeeze(axis=(-1,-2))
            
    return quad_func

def plot_contours(func, ax=None, cmap='Blues_r', levels=20, 
                  xrange=slice(-3,3,21j),
                  yrange=slice(-3,3,21j)):
    x, y = np.mgrid[xrange,
                    yrange]
    bfx = np.concatenate([x[..., None],
                          y[..., None]], axis=-1)
    f = func(bfx)
    if ax is None:
        fig, ax = plt.subplots()
    ctr = ax.contour(x, y, np.log(f), levels, cmap=cmap)
    ax.clabel(ctr, ctr.levels, inline=True, fontsize=6)
    ax.set_xlabel('$x$')
    ax.set_ylabel('$y$')
    return ax
    
# Fit a quadratic curve around this curve:
ax = plot_contours(f, 
                   levels=[0.01, 0.5, 0.8, 1.0, 1.2, 1.6, 1.8, 2.0])
x0 = np.array([0, 2])
ax.plot([x0[0]], [x0[1]], 'ro')
ax.text(x0[0], x0[1], '$x_0$')
quad_func = taylor_series_quad_approx(x0, f, grad_f, hessian_f)
# x, y = np.mgrid[-3:3:21j,
#                     -3:3:21j]
# bfx = np.concatenate([x[..., None],
#                       y[..., None]], axis=-1)
# print(bfx.shape)
# print(f(bfx).shape)
# print(grad_f(bfx).shape)
# print(quad_func(bfx).shape)
plot_contours(quad_func, ax=ax, cmap='Reds_r', 
              levels=[0.1, 0.2, 0.5, 0.8, 1.0, 1.5],
              xrange=slice(-1,2,21j),
              yrange=slice(-2,3,21j))
ax.set_title("Taylor series quadratic fit in red to the blue curve")
plt.show()
<Figure size 640x480 with 1 Axes>

Example Taylor series 1

Following the example above, fit a quadratic function to the function using Taylor series expansion near the points x0=[11]\bfx_0 = \begin{bmatrix} -1 \\ 1\end{bmatrix} then visualize the contour plots of the original function and the quadratic function (50 marks),

f(x)=x1exp((x12+x22))f(\bfx) = x_1 \exp(-(x_1^2 + x_2^2))

def f(x):
    return (x @ [1, 0]) * np.exp(-(x * x).sum(axis=-1))

def grad_f(x):
    coeff = np.array([1, 0])
    return np.exp(-(x * x).sum(axis=-1, keepdims=True)) * coeff - 2 * f(x)[..., None] * x

## Throw error if grad_f is wrong
assert check_numerical_jacobian(f, grad_f)

# def grad_f1(x):
#     coeff = np.array([1, 0])
#     return np.exp(-(x * x).sum(axis=-1, keepdims=True)) * coeff

# def hessian_f1(x):
#     coeff = np.array([1, 0])
#     return - 2 * np.exp(-(x * x).sum(axis=-1))[..., None, None] * (coeff[:, None] @ x[..., None, :])

# ## Throw error if grad_f is wrong
# assert check_numerical_jacobian(grad_f1, hessian_f1)

# def grad_f2(x):
#     return - 2 * f(x)[..., None] * x

# def hessian_f2(x):
#     ones = np.ones_like(x)
#     return (- 2 * f(x)[..., None, None] * np.eye(x.shape[-1])
#             - 2 * x[..., None] @ grad_f(x)[..., None, :])
# assert check_numerical_jacobian(grad_f2, hessian_f2)
def hessian_f(x):
    coeff = np.array([1, 0])
    ones = np.ones_like(x)
    return (- 2 * np.exp(-(x * x).sum(axis=-1))[..., None, None] * (coeff[:, None] @ x[..., None, :]) 
            - 2 * f(x)[..., None, None] * np.eye(x.shape[-1])
            - 2 * x[..., None] @ grad_f(x)[..., None, :])

assert check_numerical_jacobian(grad_f, hessian_f)

x, y = np.mgrid[-3:3:21j, -3:3:21j]
bfx = np.concatenate((x[..., None], y[..., None]), axis=-1)
f(bfx).shape, grad_f(bfx).shape, hessian_f(bfx).shape


def plot_contours(func, ax=None, cmap='Blues_r', levels=20, 
                  xrange=slice(-3,3,21j),
                  yrange=slice(-3,3,21j)):
    x, y = np.mgrid[xrange,
                    yrange]
    bfx = np.concatenate([x[..., None],
                          y[..., None]], axis=-1)
    f = func(bfx)
    if ax is None:
        fig, ax = plt.subplots()
    ctr = ax.contour(x, y, f, levels, cmap=cmap)
    ax.clabel(ctr, ctr.levels, inline=True, fontsize=6)
    ax.set_xlabel('$x$')
    ax.set_ylabel('$y$')
    return ax

# Fit a quadratic curve around this curve:
ax = plot_contours(f, 
                   levels=[-4e-1, -3e-1, -2e-1, -1e-1, -7e-2, -5e-2, -1e-2, -1e-3, 0, 1e-3],
                   xrange=slice(-2,1,21j),
                   yrange=slice(-1,1.5,21j))
x0 = np.array([-1, 1])
quad_func = taylor_series_quad_approx(x0, f, grad_f, hessian_f)
plot_contours(quad_func, ax=ax, cmap='Reds_r', 
              levels=[-0.4, -.3,-.2, -.1, -0.07, -0.05],
              xrange=slice(-2,1,21j),
              yrange=slice(-1,1.5,21j))
ax.set_title("Taylor series quadratic fit in red to the blue curve")
plt.show()
<Figure size 640x480 with 1 Axes>

Minimization by gradient descent

Example 1 : minimization by gradient descent

Find the minimizer of f(x)=0.06exp(2x1+x2)+0.05exp(x12x2)+exp(x1)f (\bfx) = 0.06\exp( 2x_1 +x_2) + 0.05\exp(x_1−2 x_2) + \exp(−x_1) .

In vector form we can write it as:

f(x)=0.06exp([2,1]x)+0.05exp([1,2]x)+exp([1,0]x)f (\bfx) = 0.06\exp( [2, 1] \bfx ) + 0.05\exp([1, -2] \bfx) + \exp([-1, 0] \bfx)
xf(x)=0.06exp([2,1]x)[2,1]+0.05exp([1,2]x)[1,2]+exp([1,0]x)[1,0]\nabla_\bfx^\top f(\bfx) = 0.06\exp( [2, 1] \bfx )[2, 1] + 0.05\exp([1, -2] \bfx)[1, -2] + \exp([-1, 0] \bfx) [-1, 0]

Algorithm 9.3 Gradient descent method.

given a starting point xdomf\bfx \in \text{dom}{f} .

repeat

  1. Choose step size αt\alpha_t

  2. Update. x:=xαtf(x)\bfx := \bfx - \alpha_t \nabla f(\bfx)

until stopping criterion is satisfied.

# Define the function
def f(x):
    return (0.06 * np.exp(x @ [2, 1]) 
            + 0.05* np.exp(x @ [1, -2]) 
            + np.exp(x @ [-1, 0]))

# Compute its derivative, the gradient function
def grad_f(x):    
    coeff1 = np.array([2, 1])
    coeff2 = np.array([1, -2])
    coeff3 = np.array([-1, 0])
    # Slicing using None, increases the dimension by 1.
    # 
    return (0.06 * np.exp(x @ coeff1)[..., None] * coeff1
            + 0.05 * np.exp(x @ coeff2)[..., None] * coeff2
            + np.exp(x @ coeff3)[..., None] * coeff3)


assert check_numerical_jacobian(f, grad_f)
f(np.zeros((2,))), grad_f(np.zeros((2,))) 
(np.float64(1.11), array([-0.83, -0.04]))
def plot_gradients(func, gradfunc):
    x, y = np.mgrid[-3:3:21j,
                    -3:3:21j]
    bfx = np.concatenate((x[..., None], y[..., None]), axis=-1)
    f = func(bfx)
    dfdx = gradfunc(bfx)
    fig, ax = plt.subplots()
    ctr = ax.contour(x, y, np.log(f), 20, cmap='Blues_r')
    ax.quiver(bfx[..., 0], bfx[..., 1], dfdx[..., 0], dfdx[..., 1])
    ax.set_xlabel('$x$')
    ax.set_ylabel('$y$')
    ax.axis('equal')
    plt.show()
    
plot_gradients(f, grad_f)
<Figure size 640x480 with 1 Axes>
# Implement the gradient descent algorithm
def minimize(x0, f, grad_func, alpha_t=0.2, maxiter=100):
    t = 0
    xt = x0
    grad_f_t = grad_func(xt)
    list_of_xts, list_of_fs = [xt], [f(xt)] # for logging
    while np.linalg.norm(grad_f_t) > 1e-4: # <-- Check for convergence
        xt = xt - alpha_t * grad_f_t # <-- Main update step
        grad_f_t = grad_func(xt) # Compute the next gradient
        
        if t >= maxiter:  # Failsafe, if the algorithm does not converge
            break
        else:
            t += 1
        list_of_xts.append(xt) # for logging
        list_of_fs.append(f(xt)) # for logging
    
    return xt, list_of_xts, list_of_fs
x0 = np.array([-2, 2])
#x0 = np.random.rand(2,2) * 4 - 2
OPTIMAL_X, list_of_xts, list_of_fs = minimize(x0,
                                              f, grad_f, 
                                              alpha_t=0.2,
                                              maxiter=1000)

fig, ax = plt.subplots()
ax.plot(list_of_fs)
ax.set_xlabel('t')
ax.set_ylabel('loss')
plt.show()
<Figure size 640x480 with 1 Axes>
from matplotlib import animation, rc
rc('animation', html='jshtml')

class Anim:
    def __init__(self, fig, ax, func):
        self.fig = fig
        self.ax = ax
        x, y = np.mgrid[-3:3:21j,
                        -3:3:21j]
        bfx = np.concatenate((x[..., None], y[..., None]), axis=-1)
        f = func(bfx)
        self.ctr = self.ax.contour(x, y, np.log(f), 20, cmap='Blues_r')
        self.ax.set_xlabel('x')
        self.ax.set_ylabel('y')
        #self.ax.clabel(self.ctr, self.ctr.levels, inline=True, fontsize=6)
        self.list_of_xs = []
        self.list_of_ys = []
        self.line2, = self.ax.plot([], [], 'r*-')

        
    def anim_init(self):
        return (self.line2,)
        
    def update(self, xt):
        self.list_of_xs.append(xt[0])
        self.list_of_ys.append(xt[1])
        self.line2.set_data(self.list_of_xs, self.list_of_ys)
        return self.line2,
    
fig, ax = plt.subplots()        
a = Anim(fig, ax, f)
animation.FuncAnimation(fig, a.update, frames=list_of_xts[::5],
                        init_func=a.anim_init, blit=True, repeat=False)
Loading...
<Figure size 640x480 with 1 Axes>
# The learning rate can be sensitive to the starting points. 
# Observe the behavior for different starting points
# For different starting points you might need different learning rate scheme

x0 = np.random.rand(2) * 6 - 3
OPTIMAL_X, list_of_xts, list_of_fs = minimize(x0,
                                              f, grad_f, 
                                              alpha_t=0.2,
                                              maxiter=200)
fig, ax = plt.subplots()        
a = Anim(fig, ax, f)
animation.FuncAnimation(fig, a.update, frames=list_of_xts[::10],
                        init_func=a.anim_init, blit=True, repeat=False)
Loading...
<Figure size 640x480 with 1 Axes>

Homework (ContinuousOptimization): Problem 1

(20 marks)

Following the example above, implement your own gradient descent algorithm that minimizes the following function (50 marks),

f(x)=x1exp((x12+x22))f(\bfx) = x_1 \exp(-(x_1^2 + x_2^2))

Test your algorithm with the starting points of x0=[11]\bfx_0 = \begin{bmatrix} -1 \\ 1\end{bmatrix} and x0=[11]\bfx_0 = \begin{bmatrix} -1 \\ -1\end{bmatrix} and learning rate of αt=0.25\alpha_t = 0.25.

x0 = np.array([-1, 1])


def f(x):
    # YOUR CODE HERE
    raise NotImplementedError()

def grad_f(x):
    # YOUR CODE HERE
    raise NotImplementedError()


## Throw error if grad_f is wrong
assert check_numerical_jacobian(f, grad_f)

# Implement the gradient descent algorithm
def minimize(x0, f, grad_func, alpha_t=0.2, maxiter=100):
    t = 0
    xt = x0
    grad_f_t = grad_func(xt)
    list_of_xts, list_of_fs = [xt], [f(xt)] # for logging
    while np.linalg.norm(grad_f_t) > 1e-4: # <-- Check for convergence
        # 1. Update xt using gradient descent update
        # 2. Compute grad_f_t with new xt
        # YOUR CODE HERE
        raise NotImplementedError()
        if t >= maxiter:  # Failsafe, if the algorithm does not converge
            break
        else:
            t += 1
        list_of_xts.append(xt) # for logging
        list_of_fs.append(f(xt)) # for logging
    
    return xt, list_of_xts, list_of_fs


OPTIMAL_X, list_of_xts, list_of_fs = minimize(x0,
                                              f, grad_f, 
                                              alpha_t=0.25,
                                              maxiter=200)

class Anim:
    def __init__(self, fig, ax, func):
        self.fig = fig
        self.ax = ax
        x, y = np.mgrid[-2:1:21j,
                        -1:1:21j]
        bfx = np.concatenate((x[..., None], y[..., None]), axis=-1)
        f = func(bfx)
        self.ctr = self.ax.contour(x, y, f, 
                                   levels=[-4e-1, -3e-1, -2e-1, -1e-1, -7e-2, -5e-2, -1e-2, -1e-3, 0, 1e-3], 
                                   cmap='Blues_r')
        self.ax.set_xlabel('x')
        self.ax.set_ylabel('y')
        #self.ax.clabel(self.ctr, self.ctr.levels, inline=True, fontsize=6)
        self.list_of_xs = []
        self.list_of_ys = []
        self.line2, = self.ax.plot([], [], 'r*-')

        
    def anim_init(self):
        return (self.line2,)
        
    def update(self, xt):
        self.list_of_xs.append(xt[0])
        self.list_of_ys.append(xt[1])
        self.line2.set_data(self.list_of_xs, self.list_of_ys)
        return self.line2,
    
    
fig, ax = plt.subplots()        
a = Anim(fig, ax, f)
anim1 = animation.FuncAnimation(fig, a.update, frames=list_of_xts[::10],
                                init_func=a.anim_init, blit=True, repeat=False)
anim1
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[13], line 14
     10     raise NotImplementedError()
     13 ## Throw error if grad_f is wrong
---> 14 assert check_numerical_jacobian(f, grad_f)
     16 # Implement the gradient descent algorithm
     17 def minimize(x0, f, grad_func, alpha_t=0.2, maxiter=100):

Cell In[4], line 55, in check_numerical_jacobian(f, jac_f, nD, **kwargs)
     53 def check_numerical_jacobian(f, jac_f,  nD=2, **kwargs):
     54     x = np.random.rand(nD)
---> 55     num_jac = numerical_jacobian(f, x, **kwargs)
     56     return np.allclose(num_jac, jac_f(x), atol=1e-06, rtol=1e-4)

Cell In[4], line 47, in numerical_jacobian(f, x, h)
     45 eye = np.eye(n)
     46 x_plus_dx = x + h * eye # n x n
---> 47 num_jac = (f(x_plus_dx) - f(x)) / h # limit definition of the formula # n x m
     48 if num_jac.ndim >= 2:
     49     num_jac = num_jac.swapaxes(-1, -2) # m x n

Cell In[13], line 6, in f(x)
      4 def f(x):
      5     # YOUR CODE HERE
----> 6     raise NotImplementedError()

NotImplementedError: 
assert np.allclose(OPTIMAL_X, [-7.07e-01,  1.06e-04], atol=1e-3, rtol=1e-2)
x0 = np.array([-1, -1])
# Repeat minmimization with a different starting point x0
# YOUR CODE HERE
raise NotImplementedError()
assert np.allclose(OPTIMAL_X, [-7.07e-01,  1.06e-04], atol=1e-3, rtol=1e-2)