Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Quasi-Newton Methods for Unconstrained Optimization

Reference: Sections 3.1 - 3.3 in Biegler (2010)

# Load required Python libraries.
import numpy as np
from scipy import linalg

# Seed the random number generator so this notebook is reproducible
# (Pyomo style guide, section 8).
np.random.seed(0)

Unconstrained Optimization with Approximate Hessian

alg3-1

SR1 update is one way to approximate BkB^k.

Library of helper functions

## Check if any element of array is NaN
def check_nan(A):
    return np.sum(np.isnan(A))


## Calculate gradient with central finite difference
def my_grad_approx(x, f, eps1, verbose=False):
    """
    Calculate gradient of function my_f using central difference formula

    Inputs:
        x - point for which to evaluate gradient
        f - function to consider
        eps1 - perturbation size

    Outputs:
        grad - gradient (vector)
    """

    n = len(x)
    grad = np.zeros(n)

    if verbose:
        print("***** my_grad_approx at x = ", x, "*****")

    for i in range(0, n):

        # Create vector of zeros except eps in position i
        e = np.zeros(n)
        e[i] = eps1

        # Finite difference formula
        my_f_plus = f(x + e)
        my_f_minus = f(x - e)

        # Diagnostics
        if verbose:
            print("e[", i, "] = ", e)
            print("f(x + e[", i, "]) = ", my_f_plus)
            print("f(x - e[", i, "]) = ", my_f_minus)

        grad[i] = (my_f_plus - my_f_minus) / (2 * eps1)

    if verbose:
        print("***** Done. ***** \n")

    return grad


## Calculate Hessian using central finite difference
def my_hes_approx(x, grad, eps2):
    """
    Calculate Hessian of function my_f using central difference formula and my_grad

    Inputs:
        x - point for which to evaluate gradient
        grad - function to calculate the gradient
        eps2 - perturbation size (for Hessian NOT gradient approximation)

    Outputs:
        H - Hessian (matrix)
    """

    n = len(x)
    H = np.zeros([n, n])

    for i in range(0, n):
        # Create vector of zeros except eps in position i
        e = np.zeros(n)
        e[i] = eps2

        # Evaluate gradient twice
        grad_plus = grad(x + e)
        grad_minus = grad(x - e)

        # Notice we are building the Hessian by column (or row)
        H[:, i] = (grad_plus - grad_minus) / (2 * eps2)

    return H


## Linear algebra calculation
def xxT(u):
    """
    Calculates u*u.T to circumvent limitation with SciPy

    Arguments:
    u - numpy 1D array

    Returns:
    u*u.T

    Assume u is a nx1 vector.
    Recall: NumPy does not distinguish between row or column vectors

    u.dot(u) returns a scalar. This function returns an nxn matrix.
    """

    n = len(u)
    A = np.zeros([n, n])
    for i in range(0, n):
        for j in range(0, n):
            A[i, j] = u[i] * u[j]

    return A


## Analyze Hessian
def analyze_hes(B):
    print(B, "\n")

    l = linalg.eigvals(B)
    print("Eigenvalues: ", l, "\n")

Symmetric Rank 1 (SR1) Update

def alg1_sr1(x0, calc_f, calc_grad, eps1, eps2, verbose=False, max_iter=1000):
    """
    Arguments:
        x0 - starting point
        calc_f - function that calculates f(x)
        calc_grad - function that calculates gradient(x)

    Outputs:
        x - iteration history of x (decision variables)
        f - iteration history of f(x) (objective value)
        p - iteration history of p (steps)
        B - Hessian approximation
    """

    # Allocate outputs as empty lists
    x = []
    f = []
    p = []
    grad = []
    B = []

    # Store starting point
    x.append(x0)
    k = 0

    flag = True

    print("Iter. \tf(x) \t\t||grad(x)|| \t||p|| \t\tmin(lambda)")

    while flag and k < max_iter:
        # Evaluate f(x) at current iteration
        f.append(calc_f(x[k]))

        # Evaluate gradient
        grad.append(calc_grad(x[k]))

        if check_nan(grad[k]):
            print("WARNING: gradient calculation returned NaN")
            break

        if verbose:
            print("\n")
            print("k = ", k)
            print("x = ", x[k])
            print("grad = ", grad[k])

        # Update Hessian approximation
        if k == 0:
            # Initialize with identity
            B.append(np.eye(len(x0)))

        else:
            # Change in x
            s = x[k] - x[k - 1]

            # Change in gradient
            y = grad[k] - grad[k - 1]

            # SR1 formulation
            u = y - B[k - 1].dot(s)
            denom = (u).dot(s)

            # Safeguard: skip test for the SR1 update.
            # Only apply the update when
            #     | (y - B^k s)^T s |  >=  C1 * ||s||^2,   C1 > 0
            # and otherwise leave B unchanged. See the "Quasi-Newton Methods"
            # lecture notes, Strategy 1 (SR1), and Biegler (2010), p. 43.
            # If the denominator is (near) zero the rank-one correction is an
            # arbitrarily large, arbitrarily ill-conditioned change built from
            # almost no new information. Note the test is two-sided: it looks at
            # the magnitude only, because -- unlike BFGS -- SR1 is allowed to
            # represent negative curvature.
            C1 = 1e-8

            if abs(denom) >= C1 * s.dot(s):
                # Formula: dB = u * u.T / (u.T * s) if u is a column vector.
                dB = xxT(u) / denom
                skipped = False
            else:
                # Skip the update: B[k] = B[k-1]
                dB = np.zeros((len(x0), len(x0)))
                skipped = True

            if verbose:
                print("s = ", s)
                print("y = ", y)
                print("SR1 update denominator, (y-B[k-1]*s).T*s = ", denom)
                print("SR1 update u = ", u)
                if skipped:
                    print("Skip test |denom| >= C1*||s||^2 failed: update skipped.")
                print("SR1 update u.T*u/(u.T*s) = \n", dB)

            B.append(B[k - 1] + dB)

        if verbose:
            print("B = \n", B[k])

        if check_nan(B[k]):
            print("WARNING: Hessian update returned NaN")
            break

        c = np.linalg.cond(B[k])
        if c > 1e12:
            flag = False
            print("Warning: Hessian approximation is near singular.")
            print("B[k] = \n", B[k])

        else:
            # Calculate step
            p.append(linalg.solve(B[k], -grad[k]))

            if verbose:
                print("p = ", p[k])

            # Take step
            x.append(x[k] + p[k])

            # Calculate norms
            norm_p = linalg.norm(p[k])
            norm_g = linalg.norm(grad[k])

            # Calculate eigenvalues of Hessian (for display only)
            ev = np.real(linalg.eigvals(B[k]))

            # print("k = ",k,"\t"f[k],"\t",norm_g,"\t",norm_p)
            print(
                k,
                "  \t{0: 1.4e} \t{1:1.4e} \t{2:1.4e} \t{3: 1.4e}".format(
                    f[k], norm_g, norm_p, np.min(ev)
                ),
            )

            # Check convergence criteria
            flag = (norm_p > eps1) and (norm_g > eps2)

            # Update iteration counter
            k = k + 1

    print("Done.")
    print("x* = ", x[-1])

    return x, f, p, B


print("Done.")
Done.

Test Case: Simple quadratic program

minx  x12+(x21)2\min_x ~~ x_1^2 + (x_2 -1)^2
def my_f_simple(x):
    return x[0] ** 2 + (x[1] - 1) ** 2


def my_grad_exact(x):
    return np.array([2 * x[0], 2 * (x[1] - 1)])

Near solution

Consider x0=[0.1,0.2]Tx_0 = [-0.1, 0.2]^T

# Specify convergence criteria
eps1 = 1e-8
eps2 = 1e-4

# Create a Lambda (anonymous) function for gradient calculation
# calc_grad = lambda x : my_grad_approx(x,my_f_simple,1E-6,verbose=False)
calc_grad = lambda x: my_grad_exact(x)

# Specify starting point
x0 = np.array([-0.1, 0.2])

# Call optimization routine
x, f, p, B = alg1_sr1(
    x0, my_f_simple, calc_grad, eps1, eps2, verbose=False, max_iter=50
)

# SR1 Hessian approximation
print("\nSR1 Hessian approximation. B[k] =")
analyze_hes(B[-1])

# Actual Hessian
print("True Hessian at x*. B =")
analyze_hes(my_hes_approx(x[-1], calc_grad, 1e-6))
Iter. 	f(x) 		||grad(x)|| 	||p|| 		min(lambda)
0   	 6.5000e-01 	1.6125e+00 	1.6125e+00 	 1.0000e+00
1   	 6.5000e-01 	1.6125e+00 	8.0623e-01 	 1.0000e+00
2   	 0.0000e+00 	0.0000e+00 	0.0000e+00 	 1.0000e+00
Done.
x* =  [0. 1.]

SR1 Hessian approximation. B[k] =
[[1.01538462 0.12307692]
 [0.12307692 1.98461538]] 

Eigenvalues:  [1.+0.j 2.+0.j] 

True Hessian at x*. B =
[[2. 0.]
 [0. 2.]] 

Eigenvalues:  [2.+0.j 2.+0.j] 

Far from solution

Consider x0=[100,500]Tx_0 = [-100, 500]^T

# Specify starting point
x0 = np.array([-100, 500])

# Call optimization routine
x, f, p, B = alg1_sr1(
    x0, my_f_simple, calc_grad, eps1, eps2, verbose=False, max_iter=50
)

# SR1 Hessian approximation
print("\nSR1 Hessian approximation. B[k] =")
analyze_hes(B[-1])

# Actual Hessian
print("True Hessian at x*. B =")
analyze_hes(my_hes_approx(x[-1], calc_grad, 1e-6))
Iter. 	f(x) 		||grad(x)|| 	||p|| 		min(lambda)
0   	 2.5900e+05 	1.0178e+03 	1.0178e+03 	 1.0000e+00
1   	 2.5900e+05 	1.0178e+03 	5.0892e+02 	 1.0000e+00
2   	 5.0487e-27 	1.4211e-13 	1.1604e-13 	 1.0000e+00
Done.
x* =  [-5.1938169e-14  1.0000000e+00]

SR1 Hessian approximation. B[k] =
[[ 1.03860989 -0.19266335]
 [-0.19266335  1.96139011]] 

Eigenvalues:  [1.+0.j 2.+0.j] 

True Hessian at x*. B =
[[2. 0.]
 [0. 2.]] 

Eigenvalues:  [2.+0.j 2.+0.j] 

Activity/Discussion

  • Does the number of iterations depend on the starting point for this problem?

  • How many iterations are needed for Newton’s method to converge for a positive definite quadratic program using exact second derivative information?

  • Why does the SR1 update not converge to the true Hessian?

Test Case: Example 2.19

ex2-19
## Define Python function to calculate objective
def my_f_ex_2_19(x, verbose=False):
    """Evaluate function given above at point x

    Inputs:
        x - vector with 2 elements

    Outputs:
        f - function value (scalar)
    """
    # Constants
    a = np.array([0.3, 0.6, 0.2])
    b = np.array([5, 26, 3])
    c = np.array([40, 1, 10])

    # Intermediates. Recall Python indicies start at 0
    u = x[0] - 0.8
    s = np.sqrt(1 - u)
    s2 = np.sqrt(1 + u)
    v = x[1] - (a[0] + a[1] * u**2 * s - a[2] * u)
    alpha = (
        -b[0] + b[1] * u**2 * s2 + b[2] * u
    )  # September 5, 2018: changed 's' to 's2'
    beta = c[0] * v**2 * (1 - c[1] * v) / (1 + c[2] * u**2)
    f = alpha * np.exp(-beta)

    if verbose:
        print("##### my_f at x = ", x, "#####")
        print("u = ", u)
        print("sqrt(1-u) = ", s)
        print("sqrt(1+u) = ", s2)
        print("v = ", v)
        print("alpha = ", alpha)
        print("beta = ", beta)
        print("f(x) = ", f)
        print("##### Done. #####\n")

    return f

x0x_0 somewhat near solution

Consider:

x0=[0.3,0.1]Tx_0 = [0.3, 0.1]^T
# Create a Lambda (anonymous) function for gradient calculation
calc_grad = lambda x: my_grad_approx(x, my_f_ex_2_19, 1e-6, verbose=False)

# Specify starting point
x0 = np.array([0.3, 0.1])

# Call optimization routine
x, f, p, B = alg1_sr1(
    x0, my_f_ex_2_19, calc_grad, eps1, eps2, verbose=False, max_iter=250
)

# SR1 Hessian approximation
print("\nSR1 Hessian approximation. B[k] =")
analyze_hes(B[-1])

# Actual Hessian
print("True Hessian at x*. B =")
analyze_hes(my_hes_approx(x[-1], calc_grad, 1e-6))
Iter. 	f(x) 		||grad(x)|| 	||p|| 		min(lambda)
0   	-3.6022e-02 	8.3847e-01 	8.3847e-01 	 1.0000e+00
1   	-4.1276e-02 	4.7785e-01 	3.0223e-01 	 1.0000e+00
2   	-2.2301e+00 	2.1236e+01 	3.0865e-01 	-6.8856e+01
3   	-4.1260e-02 	4.4645e-01 	1.3053e-01 	-6.7478e+01
4   	-1.0354e-01 	1.1855e+00 	1.5062e-01 	-7.1805e+01
5   	-3.7115e-02 	3.9982e-01 	1.3096e-02 	-5.8783e+01
6   	-3.4039e-02 	3.6164e-01 	5.9865e-02 	-1.4365e+01
7   	-2.2398e-02 	2.0377e-01 	4.2988e-02 	-9.5209e+00
8   	-1.7133e-02 	1.1914e-01 	4.2522e-02 	-6.4394e+00
9   	-1.4292e-02 	5.7282e-02 	2.4810e-02 	-4.8015e+00
10   	-1.3542e-02 	2.5813e-02 	1.0353e-02 	-2.8612e+00
11   	-1.3373e-02 	1.0933e-02 	8.4091e-03 	-1.6117e+00
12   	-1.3325e-02 	7.7765e-04 	7.5906e-04 	-1.6030e+00
13   	-1.3324e-02 	1.7106e-05 	1.4129e-05 	-1.5761e+00
Done.
x* =  [0.80557705 0.96556999]

SR1 Hessian approximation. B[k] =
[[-1.48333926 -0.21589308]
 [-0.21589308 -1.07333657]] 

Eigenvalues:  [-1.57605485+0.j -0.98062098+0.j] 

True Hessian at x*. B =
[[-1.47003026 -0.2060227 ]
 [-0.2060227  -1.06562155]] 

Eigenvalues:  [-1.55649865+0.j -0.97915316+0.j] 

x0x_0 far from solution

Consider:

x0=[0.1,0.2]Tx_0 = [-0.1, 0.2]^T
# Specify starting point
x0 = np.array([-0.1, 0.2])

# Call optimization routine
x, f, p, B = alg1_sr1(
    x0, my_f_ex_2_19, calc_grad, eps1, eps2, verbose=False, max_iter=250
)

# SR1 Hessian approximation
print("\nSR1 Hessian approximation. B[k] =")
analyze_hes(B[-1])

# Actual Hessian
print("True Hessian at x*. B =")
analyze_hes(my_hes_approx(x[-1], calc_grad, 1e-6))
Iter. 	f(x) 		||grad(x)|| 	||p|| 		min(lambda)
0   	-4.5540e-04 	9.2580e-03 	9.2580e-03 	 1.0000e+00
1   	-5.4879e-04 	1.0959e-02 	5.9578e-02 	-1.8395e-01
2   	-1.5604e-04 	3.4877e-03 	2.7627e-02 	-1.2568e-01
3   	-8.3235e-05 	1.9563e-03 	3.5000e-02 	-5.5390e-02
4   	-3.6006e-05 	9.0023e-04 	2.9559e-02 	-3.0011e-02
5   	-1.7097e-05 	4.4964e-04 	2.9246e-02 	-1.5078e-02
6   	-7.9092e-06 	2.1835e-04 	2.7389e-02 	-7.7836e-03
7   	-3.7242e-06 	1.0745e-04 	2.6349e-02 	-3.9664e-03
8   	-1.7531e-06 	5.2702e-05 	2.5208e-02 	-2.0268e-03
Done.
x* =  [-0.11328951 -0.05033867]

SR1 Hessian approximation. B[k] =
[[ 1.00141633e+00 -4.82365648e-02]
 [-4.82365648e-02  2.91945446e-04]] 

Eigenvalues:  [ 1.00373512+0.j -0.00202684+0.j] 

True Hessian at x*. B =
[[ 6.99095467e-05 -2.18304769e-04]
 [-2.18304769e-04 -7.02715474e-04]] 

Eigenvalues:  [ 0.00012732+0.j -0.00076013+0.j] 

Activity/Discussion

  • Classify each candidate solution.

  • Is the SR1 approximation always positive definite?

Broyden update with Cholesky factorization

As part of Algorithms Homework 3, you will adapt this example and implement the BFGS (Broyden-Fletcher-Goldfarb-Shanno) update formula.

You may decide to use the Cholesky factorization of BkB^{k},

Bk=Lk(Lk)T,B^{k} = L^{k} (L^{k})^T,

to make your BFGS update more efficient. (This is not required). Let’s see how to do Cholesky factorization with SciPy.

## Create random P.D. and symmetric matrix
B_ = np.eye(3) + xxT(np.random.normal(0, 1, 3))
print("B = \n", B_, "\n")

## Perform Cholesky factorization.
# By default, lower=False and L is upper triangular. Either works here,
# but we prefer L to be lower triangular for convention.
L = linalg.cholesky(B_, lower=True)
print("L = \n", L, "\n")

## Reconstruct B
print("L*L.T = \n", L.dot(L.T), "\n")
B = 
 [[4.11188068 0.70589826 1.72654504]
 [0.70589826 1.16012579 0.39164906]
 [1.72654504 0.39164906 1.95792804]] 

L = 
 [[2.02777728 0.         0.        ]
 [0.3481143  1.01928515 0.        ]
 [0.85144708 0.09344604 1.10645097]] 

L*L.T = 
 [[4.11188068 0.70589826 1.72654504]
 [0.70589826 1.16012579 0.39164906]
 [1.72654504 0.39164906 1.95792804]]