Метод Ньютона

На прошлом семинаре...

  1. Метод сопряжённых градиентов
  2. Метод тяжёлого шарика
  3. Ускоренный метод Нестерова
  4. Концепция нижних оценок

Идея метода Ньютона

Рассмотрим задачу

$$ \min\limits_{x\ \in \mathbb{R}^n} f(x). $$
  • Градиентный спуск $\equiv$ линейная аппроксимация $f$
  • Метод Ньютона $\equiv$ квадратичная аппроксимация $f$:
$$ f(x + h) \approx f(x) + \langle f'(x), h \rangle + \frac{1}{2}h^{\top}f''(x)h \to \min_{h} $$

Из необходимого условия минимума:

$$ f'(x) + f''(x) h = 0, \qquad h^* = -(f''(x))^{-1} f'(x) $$

Является ли найденное направление направлением убывания?

Проверим знак скалярного произведения $\langle f'(x), h^* \rangle$.

$$ \langle f'(x), h^* \rangle = -(f')^{\top}(x) (f''(x))^{-1} f'(x) < 0 \Leftarrow f''(x) \succ 0 $$

Вопрос: а что если при некотором $k^*$ гессиан станет неопределён?

Метод Ньютона

  • Классический метод Ньютона: $\alpha_k \equiv 1$
  • Демпфированный метод Ньютона: $\alpha_k$ выбирается на каждой итерации по заданному правилу
def NewtonMethod(f, x0, epsilon, **kwargs):

    x = x0

    while True:

        h = ComputeNewtonStep(x, f, **kwargs)

        if StopCriterion(x, f, h, **kwargs) < epsilon:

            break

        alpha = SelectStepSize(x, h, f, **kwargs)

        x = x + alpha * h

    return x

Теорема сходимости (Ю. Е. Нестеров Введение в выпуклую оптимизацию, $\S$ 1.2)

Теорема. Пусть функция $f(x)$

  • дважды дифференцируема и её гессиан удовлетворяет условию Липшица с константой $M$
  • существует точка локального минимума с положительно определённым гессианом
$$ f''(x^*) \succeq l\mathbf{I}, \; l > 0 $$
  • начальная точка $x_0$ достаточно близка к точке минимума, в частности
$$ \|x_0 - x^*\|_2 \leq \frac{2l}{3M} $$

Тогда метод Ньютона сходится квадратично:

$$ \|x_{k+1} - x^* \|_2 \leq \dfrac{M\|x_k - x^*\|^2_2}{2 (l - M\|x_k - x^*\|_2)} $$

Пример

Применим метод Ньютона для поиска корня следующей функции

$$ \varphi(t) = \dfrac{t}{\sqrt{1+t^2}} $$

и определим область сходимости.

Аффинная инвариантность

Рассмотрим функцию $f(x)$ и невырожденное преобразование с матрицей $A$.

Выясним, как изменится шаг метода Ньютона после преобразования $A$.

Пусть $x = Ay$ и $g(y) = f(Ay)$. Тогда

$$ g(y + u) \approx g(y) + \langle g'(y), u \rangle + \frac{1}{2} u^{\top} g''(y) u \to \min_{u} $$

и

$$ u^* = -(g''(y))^{-1} g'(y) \qquad y_{k+1} = y_k - (g''(y_k))^{-1} g'(y_k) $$

или

\begin{align*} y_{k+1} & = y_k - (A^{\top}f''(Ay_k)A)^{-1} A^{\top}f'(Ay_k)\\ & = y_k - A^{-1}(f''(Ay_k))^{-1}f'(Ay_k) \end{align*}

Таким образом,

$$ Ay_{k+1} = Ay_k - (f''(Ay_k))^{-1}f'(Ay_k) \quad x_{k+1} = x_k - (f''(x_k))^{-1}f'(x_k) $$

Следовательно, направление метода Ньютона преобразуется при

линейном преобразовани так же, как и координаты!

Использование метода сопряжённых градиентов в методе Ньютона

  • В методе Ньютона надо решать линейную систему $H(x_k) h_k = -f'(x_k)$ для поиска направлению движения
  • Если функция сильно выпуклая, то $H(x_k) \in \mathbb{S}^n_{++}$ и для решения системы можно использовать метод сопряжённых градиентов. В этом случае метод называют неточный метод Ньютона.
  • Что нового это даёт
    • Явно хранить гессиан не нужно, достаточно умножать его на вектор
    • Можно регулировать точность решения системы и не решать её очень точно вдали от решения. Важно: неточное решение может не быть направлением убывания!
    • Сходимость будет только локально сверхлинейной при начале выбора шага с $\alpha_0 = 1$, как и в методе Ньютона

Вычислительная сложность и эксперименты

Узкие места метода Ньютона:

  • формирование и хранение гессиана
  • решение систем линейных уравнений
$$ f''(x_k)h = -f'(x_k) $$

Сравнение с градиентным спуском

Вспомним задачу нахождения аналитического центра системы неравенств $Ax \leq 1$ при условии $|x_i| \leq 1$

$$ f(x) = - \sum_{i=1}^m \log(1 - a_i^{\top}x) - \sum\limits_{i = 1}^n \log (1 - x^2_i) \to \min_x $$$$ f'(x) - ? \quad f''(x) - ? $$


In [1]:
import numpy as np

USE_COLAB = False
if USE_COLAB:
    !pip install git+https://github.com/amkatrutsa/liboptpy
        
import liboptpy.unconstr_solvers as methods
import liboptpy.step_size as ss

n = 1000
m = 200
x0 = np.zeros((n,))
A = np.random.rand(n, m) * 10

Точное решение с помощью CVXPy


In [2]:
import cvxpy as cvx
x = cvx.Variable((n, 1))

obj = cvx.Minimize(cvx.sum(-cvx.log(1 - A.T * x)) - 
                   cvx.sum(cvx.log(1 - cvx.square(x))))
prob = cvx.Problem(obj)
prob.solve(solver="SCS", verbose=True, max_iters=1000)
print("Optimal value =", prob.value)


----------------------------------------------------------------------------
	SCS v1.2.6 - Splitting Conic Solver
	(c) Brendan O'Donoghue, Stanford University, 2012-2016
----------------------------------------------------------------------------
Lin-sys: sparse-indirect, nnz in A = 205200, CG tol ~ 1/iter^(2.00)
eps = 1.00e-04, alpha = 1.50, max_iters = 1000, normalize = 1, scale = 1.00
Variables n = 3200, constraints m = 6600
Cones:	soc vars: 3000, soc blks: 1000
	exp vars: 3600, dual exp vars: 0
Setup time: 8.30e-03s
----------------------------------------------------------------------------
 Iter | pri res | dua res | rel gap | pri obj | dua obj | kap/tau | time (s)
----------------------------------------------------------------------------
     0|      inf       inf       nan      -inf      -inf       inf  2.06e-02 
   100| 1.63e+01  3.31e+00  9.83e-03 -8.80e+03 -8.63e+03  1.82e-13  9.62e-01 
   200| 1.45e+00  9.75e-01  1.70e-03 -1.94e+03 -1.94e+03  3.26e-13  1.90e+00 
   300| 4.54e-01  5.37e-01  2.15e-04 -1.60e+03 -1.60e+03  3.44e-13  2.78e+00 
   400| 2.21e-01  2.78e-01  1.06e-04 -1.47e+03 -1.47e+03  3.46e-13  3.78e+00 
   500| 1.30e-01  1.30e-01  1.62e-04 -1.41e+03 -1.42e+03  3.43e-13  4.90e+00 
   600| 7.62e-02  4.85e-02  1.24e-04 -1.39e+03 -1.39e+03  3.40e-13  6.06e+00 
   700| 3.83e-02  1.01e-02  6.82e-05 -1.37e+03 -1.37e+03  3.38e-13  7.30e+00 
   800| 1.61e-02  3.08e-03  2.66e-05 -1.37e+03 -1.37e+03  3.37e-13  8.40e+00 
   900| 7.40e-03  4.81e-03  5.51e-06 -1.37e+03 -1.37e+03  3.37e-13  9.55e+00 
  1000| 5.86e-03  3.15e-03  1.66e-06 -1.37e+03 -1.37e+03  3.37e-13  1.06e+01 
----------------------------------------------------------------------------
Status: Solved/Inaccurate
Hit max_iters, solution may be inaccurate
Timing: Solve time: 1.06e+01s
	Lin-sys: avg # CG iterations: 3.39, avg solve time: 3.04e-03s
	Cones: avg projection time: 7.42e-03s
----------------------------------------------------------------------------
Error metrics:
dist(s, K) = 1.3685e-03, dist(y, K*) = 2.2204e-16, s'y/|s||y| = -3.9569e-11
|Ax + s - b|_2 / (1 + |b|_2) = 5.8556e-03
|A'y + c|_2 / (1 + |c|_2) = 3.1545e-03
|c'x + b'y| / (1 + |c'x| + |b'y|) = 1.6615e-06
----------------------------------------------------------------------------
c'x = -1367.6766, -b'y = -1367.6720
============================================================================
Optimal value = -1367.6765678297181

Вспомогательные функции


In [3]:
f = lambda x: -np.sum(np.log(1 - A.T.dot(x))) - np.sum(np.log(1 - x*x))
grad_f = lambda x: np.sum(A.dot(np.diagflat(1 / (1 - A.T.dot(x)))), axis=1) + 2 * x / (1 - np.power(x, 2))
hess_f = lambda x: (A.dot(np.diagflat(1 / (1 - A.T.dot(x))**2))).dot(A.T) + np.diagflat(2 * (1 + x**2) / (1 - x**2)**2)

Реализация метода Ньютона


In [4]:
def Newton(f, gradf, hessf, x0, epsilon, num_iter, line_search, 
                    disp=False, callback=None, **kwargs):
    x = x0.copy()
    iteration = 0
    opt_arg = {"f": f, "grad_f": gradf}
    for key in kwargs:
        opt_arg[key] = kwargs[key]
    while True:
        gradient = gradf(x)
        hess = hessf(x)
        h = np.linalg.solve(hess, -gradient)
        alpha = line_search(x, h, **opt_arg)
        x = x + alpha * h
        if callback is not None:
            callback(x)
        iteration += 1
        if disp:
            print("Current function val =", f(x))
            print("Current gradient norm = ", np.linalg.norm(gradf(x)))
        if np.linalg.norm(gradf(x)) < epsilon:
            break
        if iteration >= num_iter:
            break
    res = {"x": x, "num_iter": iteration, "tol": np.linalg.norm(gradf(x))}
    return res

Сравнение с градиентным спуском


In [5]:
newton = methods.so.NewtonMethod(f, grad_f, hess_f, ss.Backtracking("Armijo", rho=0.9, beta=0.1, init_alpha=1.))
x_newton = newton.solve(x0, tol=1e-6, max_iter=50, disp=True)

gd = methods.fo.GradientDescent(f, grad_f, ss.Backtracking("Armijo", rho=0.9, beta=0.1, init_alpha=1.))
x_gd = gd.solve(x0, tol=1e-6, max_iter=50, disp=True)


Required tolerance achieved!
Convergence in 14 iterations
Function value = -1368.680140172699
Norm of gradient = 7.605565425028795e-10
/Users/alex/anaconda3/envs/cvxpy/lib/python3.6/site-packages/ipykernel_launcher.py:1: RuntimeWarning: invalid value encountered in log
  """Entry point for launching an IPython kernel.
Required tolerance achieved!
Convergence in 46 iterations
Function value = -1368.6801401726993
Norm of gradient = 2.984537026714002e-07

In [6]:
%matplotlib inline
import matplotlib.pyplot as plt

if not USE_COLAB:
    plt.rc("text", usetex=True)
    
plt.figure(figsize=(12, 8))
# Newton
plt.semilogy([np.linalg.norm(grad_f(x)) for x in newton.get_convergence()], label="$\| f'(x_k) \|^{N}_2$")
# Gradient
plt.semilogy([np.linalg.norm(grad_f(x)) for x in gd.get_convergence()], label="$\| f'(x_k) \|^{G}_2$")
plt.xlabel(r"Number of iterations, $k$", fontsize=26)
plt.ylabel(r"Convergence rate", fontsize=26)
plt.xticks(fontsize = 24)
plt.yticks(fontsize = 24)
plt.legend(loc="best", fontsize=24)


Out[6]:
<matplotlib.legend.Legend at 0x82aa687f0>

Сравнение времени работы


In [7]:
%timeit newton.solve(x0, tol=1e-6, max_iter=50)
%timeit gd.solve(x0, tol=1e-6, max_iter=50)


612 ms ± 138 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
/Users/alex/anaconda3/envs/cvxpy/lib/python3.6/site-packages/ipykernel_launcher.py:1: RuntimeWarning: invalid value encountered in log
  """Entry point for launching an IPython kernel.
256 ms ± 41.6 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
  • Метод Ньютона даёт очень точное решение за длительное время
  • Градиентный спуска даёт не очень точное решение, но гораздо быстрее
  • Часто бывает, что очень точное решение не требуется, поэтому градиентный спуск может быть предпочтительнее

Pro & Contra

Pro

  • квадратичная сходимость вблизи решения
  • высокая точность полученного решения
  • аффинная инвариантность
  • параметры слабо влияют на скорость сходимости

Contra

  • необходимо хранить гессиан на каждой итерации: $O(n^2)$ памяти
  • необходимо решать линейные системы: $O(n^3)$ операций
  • гессиан может оказаться вырожден
  • гессиан может не быть положительно определён $\to$ направление $-(f''(x))^{-1}f'(x)$ может не быть направлением убывания

Сравнение с градиентным методом (Б.Т. Поляк Введение в оптимизацию, гл. 3, $\S$ 1 )

Метод Скорость сходимости Сложность Аффинная инвариантность Требования к $f(x)$
Градиентный спуск Глобально линейная $O(n) + $ определение шага Нет Дифференцируема; градиент липшицев
Метод Ньютона Локально квадратичная $O(n^3) + $ определение шага Да Дважды диференцируема; гессиан липшицев, положительно определён

Что дальше?

  • Сложность: как избавиться от решения систем линейных уравнений и хранения гессиана?
  • Сходимость: как совместить локально квадратичную и глобально линейную скорости? Желательно получить глобально квадратичную сходимость!
  • Требования к $f(x)$ необходимо минимизировать
  • Квазиньютоновские методы частично решают эти проблемы

Как уменьшить сложность хранения и вычисления?

  • Сложность вычисления можно уменьшить с помощью

    • Квазиньютоновские методы, они же методы переменной метрики
    • Требуется хранение матрицы $n \times n$
  • Сложность вычисления и хранения можно уменьшить

    • квазиньютоновские методы с ограниченной памятью, например L-BFGS (Limited Broyden-Fletcher-Goldfarb-Shanno)
    • НЕ требуется хранить матрицу
    • вместо этого хранятся $k \ll n$ векторов из $\mathbb{R}^n$

Единообразный способ получения метода Ньютона и градиентного спуска

  • градиентный метод получен из аппроксимации первого порядка:
$$ f_G(x) \approx f(y) + \langle f'(y), x - y \rangle + \frac{1}{2}(x-y)^{\top} \frac{1}{\alpha}I(x - y) $$

причём при $\alpha \in (0, 1/L], f(x) \leq f_G(x)$, то есть $f_G$ - глобальная оценка $f(x)$

  • метод Ньютона получен из аппроксимации второго порядка
$$ f_N(x) \approx f(y) + \langle f'(y), x - y \rangle + \frac{1}{2} (x-y)^{\top}f''(y)(x-y) $$

Идея: использовать промежуточную аппроксимацию вида

$$ f_q(x) \approx f(y) + \langle f'(y), x - y \rangle + \frac{1}{2} (x-y)^{\top}{\color{red}{B(y)}}(x-y), $$

которая даёт переход к следующей точке:

$$ x_{k+1} = x_k - \alpha_k B^{-1}_k f'(x_k) = x_k - \alpha_k H_k f'(x_k) $$

Немного истории...

  • Первый квазиньютоновский метод придумал физик William Davidon в середине 1950-х для ускорения своих вычислений на ненадёжных компьютерах
  • Его статью с описанием предложенного метода не приняли к публикации, и она оставалась техническим отчётом
    более 30 лет
  • Опубликована в 1991 году в первом выпуске SIAM Journal on Optimization

Общая схема квазиньютоновских методов

def QuasiNewtonMethod(f, x0, epsilon, **kwargs):

    x = x0

    H = I

    while True:

        h = -H.dot(grad_f(x))

        if StopCriterion(x, f, h, **kwargs) < epsilon:

            break

        alpha = SelectStepSize(x, h, f, **kwargs)

        x = x + alpha * h

        H = UpdateH(H, f(x), grad_f(x))

    return x

Как искать $B_{k+1}$?

В точке $x_{k+1}$ имеем следующую аппрокисмацию:

$$ f_q(h) \approx f(x_{k+1}) + \langle f'(x_{k+1}), h \rangle + \frac{1}{2}h^{\top}B_{k+1}h $$

Из определения, очевидно, что $B_{k+1} \in \mathbb{S}^n_{++}$. Какие требования естественно наложить на $f_q(h)$?

$$ f_q'(-\alpha_k h_k) = f'(x_k) \qquad f'_q(0) = f'(x_{k+1}), $$

где первое условие даёт

$$ f'(x_{k+1}) - \alpha_k B_{k+1}h_k = f'(x_k), $$

а второе выполняется автоматически.

Квазиньютоновское уравнение (Secant equation)

Из первого условия получаем

$$ B_{k+1}s_k = y_k, $$

где $s_k = x_{k+1} - x_k$ и $y_k = f'(x_{k+1}) - f'(x_k)$.

Это уравнение будет иметь решение только при $s^{\top}_k y_k > 0$. Почему?

Вопрос: единственным ли образом определено $B_{k+1}$?

Как однозначно определить $B_{k+1}$?

\begin{align*} & \min_B \| B_k - B \| \\ \text{s.t. } & B = B^{\top}\\ & Bs_k = y_k \end{align*}

DFP (Davidon-Fletcher-Powell)

$$ B_{k+1} = (I - \rho_k y_k s^{\top}_k)B_k(I - \rho_k s_ky^{\top}_k) + \rho_k y_k y^{\top}_k, $$

где $\rho_k = \dfrac{1}{y^{\top}_k s_k}$,

или с помощью формулы Шермана-Морисона-Вудбери

$$ B^{-1}_{k+1} = H_{k+1} = H_k - \dfrac{H_ky_k y_k^{\top}H_k}{y^{\top}_kH_ky_k} + \dfrac{s_ks^{\top}_k}{y^{\top}_ks_k} $$

Вопрос: какой ранг у разности матриц $B_{k+1} (H_{k+1})$ и $B_{k} (H_{k})$?

Вывод

Общая идея квазиньютоновских методов:

вместо полного пересчёта гессиана на каждой итерации обновлять

текущую его аппроксимацию с помощью легко вычислимого

преобразования

BFGS

Вопрос: какая естественная модификация метода DFP?

\begin{align*} & \min_H \| H_k - H \| \\ \text{s.t. } & H = H^{\top}\\ & Hy_k = s_k \end{align*}

Формула пересчёта для метода BFGS:

$$ H_{k+1} = (I - \rho_k s_ky^{\top}_k)H_k(I - \rho_k y_k s^{\top}_k) + \rho_k s_k s^{\top}_k, $$

где $\rho_k = \dfrac{1}{y^{\top}_k s_k}$

Детали реализации

  • Не должно быть операций сложностью $O(n^3)$, то есть никаких матричных умножений и решений линейных систем (cf. реализацию в SciPy v. 0.18.1)
  • Только правило Вольфа гарантирует соблюдения условия кривизны $y_k^{\top}s_k > 0$
  • Параметры в правиле Вольфа обычно следующие
    • $\alpha_0 = 1$ необходим для сверхлинейной скорости
    • $\beta_1 = 10^{-4}$, $\beta_2 = 0.9$
  • Способы инициализации $H_0$
    • единичная матрица
    • $H_0 = \frac{y_0^{\top}s_0}{y_0^{\top}y_0}I$ после первого шага, но до вычисления $H_1$.При вычислении $x_1$ используется $H_0 = I$
    • $H_0 = \delta \|g_0\|^{-1}_2 I$, параметр $\delta$ необходимо заранее задать
def update_H(x_next, x_current):

    current_grad = grad(x_next)

    s = x_next - x_current

    y = current_grad - grad_mem[-1]

    rho = 1. / y.dot(s)

    if H is None:

        H = np.eye(x_current.shape[0]) / y.dot(y) / rho

    Hy = H.dot(y)

    Hys = np.outer(Hy, s)

    ss = np.outer(s, s)

    H = rho * ss + H - rho * Hys - rho * Hys.T + rho**2 * y.dot(Hy) * ss

    x_current = x_next

    return H

Сходимость

Теорема

Пусть $f$ дважды непрерывно дифференцируема и её гессиан липшицев, также пусть последовательность генерируемая методом BFGS сходится к точке $x^*$ так что $\sum_{k=1}^{\infty} \|x_k - x^*\| < \infty$. Тогда $x_k \to x^*$ сверхлинейно.

Самокоррекция

  • Если BFGS на некоторой итерации даёт плохую оценку обратного гессиана, то через несколько итераций это недоразумение будет автоматически исправлено, то есть метод сам скорректирует грубую оценку гессиана
  • Это свойство появляется только при правильном способе выбора шага, например при использовании правила Вольфа
  • Метод DFP существенно хуже корректирует неточные оценки обратного гессиана
  • Всё это будет ниже проиллюстрировано на примерах

BFGS с ограниченной памятью (L-BFGS)

  • В методе BFGS нужна не сама матрица $H$, а только функция умножения её на вектор
  • Поскольку требуется локальная оценка гессиана, старые значения векторов $s$ и $y$ могут портить текущую оценку

Идея

  • Хранить $k \ll n$ последних векторов $s$ и $y$ - снижение требуемой памяти с $n^2$ до $kn$
  • Выполнение умножения на вектор рекурсивно, без явного формирования матрицы $H$

def get_lbfgs_direction(x):

    if H is None:

        current_grad = grad(x)

        return -current_grad

    else:

        q = current_grad

        alpha = np.zeros(len(s_hist))

        rho = np.zeros(len(s_hist))

        for i in range(len(s_hist) - 1, -1, -1):

            rho[i] = 1. / s_hist[i].dot(y_hist[i])

            alpha[i] = s_hist[i].dot(q) * rho[i]

            q = q - alpha[i] * y_hist[i]

        r = q * H

        for i in range(len(s_hist)):

            beta = rho[i] * y_hist[i].dot(r)

            r = r + s_hist[i] * (alpha[i] - beta)

    return -r

Barzilai-Borwein method

  • Первая статья об этом методе опубликована в 1988, в журнале IMA Journal of Numerical Analysis
  • Статья на NIPS 2016 о модификации этого метода в случае использования стохастической оценки градиента
  • Идея: комбинация идеи наискорейшего спуска и квазиньютоновского метода

Идея метода

  • Наискорейший спуск: $x_{k+1} = x_k - \alpha_k f'(x_k)$, $\alpha_k = \arg \min\limits_{\alpha > 0} f(x_{k+1})$
  • Метод Ньютона: $x_{k+1} = x_k - (f''(x_k))^{-1} f'(x_k)$
  • Аппроксимация гессиана диагональной матрицей:
$$ \alpha_k f'(x_k) = \alpha_k I f'(x_k) = \left( \frac{1}{\alpha_k} I \right)^{-1} f'(x_k) \approx f''(x_k))^{-1} f'(x_k) $$
  • Как найти $\alpha_k$?

Снова квазиньютоновское уравнение (Secant equation)

  • Для точного гессиана $$ f''(x_{k})(x_{k} - x_{k-1}) = f'(x_{k}) - f'(x_{k-1}) $$
  • Для приближения
$$ \alpha_k^{-1} s_{k-1} \approx y_{k-1} $$
  • Задача аппроксимации одного вектора с помощью масштабирования другого
  • Простейший квазиньютоновский метод вырождается в поиск оптимального шага

Три способа найти $\alpha_k$

  • Первый способ

    • Задача

      $$ \min_{\beta} \|\beta s_{k-1} - y_{k-1} \|^2_2 $$

    • Решение

      $$ \alpha = \frac{1}{\beta} = \frac{s^{\top}_{k-1} s_{k-1}}{s^{\top}_{k-1} y_{k-1}} $$

  • Второй способ

    • Задача

      $$ \min_{\alpha} \| s_{k-1} - \alpha y_{k-1} \|^2_2 $$

    • Решение

      $$ \alpha = \frac{s^{\top}_{k-1} y_{k-1}}{y^{\top}_{k-1} y_{k-1}} $$

  • Третий способ называется немонотонный линейный поиск: специальная модификация правил Армихо, учитывающая историю изменений значения функции, статья 2004 г. в SIAM Journal on Optimization

Эксперименты

Поиск аналитического центра системы неравенств

$$ f(x) = - \sum_{i=1}^m \log(1 - a_i^{\top}x) - \sum\limits_{i = 1}^n \log (1 - x^2_i) \to \min_x $$

In [7]:
import numpy as np
import liboptpy.unconstr_solvers as methods
import liboptpy.step_size as ss
%matplotlib inline
import matplotlib.pyplot as plt
import scipy.optimize as scopt
plt.rc("text", usetex=True)

In [8]:
n = 3000
m = 100
x0 = np.zeros(n)
max_iter = 100
tol = 1e-5
A = np.random.rand(m, n) * 10

In [9]:
f = lambda x: -np.sum(np.log(1 - A.dot(x))) - np.sum(np.log(1 - x*x))
grad_f = lambda x: np.sum(A.T / (1 - A.dot(x)), axis=1) + 2 * x / (1 - np.power(x, 2))

In [10]:
def bb_method(f, gradf, x0, tol=1e-6, maxiter=100, callback=None, alpha_type=1):
    it = 0
    x_prev = x0.copy()
    current_tol = np.linalg.norm(gradf(x_prev))
    alpha = 1e-4
    while current_tol > tol and it < maxiter:
        it += 1
        current_grad = gradf(x_prev)
        if it != 1:
            g = current_grad - prev_grad
            if alpha_type == 1:
                alpha = g.dot(s) / g.dot(g)
            elif alpha_type == 2:
                alpha = s.dot(s) / g.dot(s)
        if callback:
            callback(x_prev)
        x_next = x_prev - alpha * current_grad
        current_tol = np.linalg.norm(gradf(x_next))
        prev_grad = current_grad
        s = x_next - x_prev
        x_prev = x_next
    if callback:
        callback(x_prev)
    return x_next

In [11]:
method = {
    "BB 1": methods.fo.BarzilaiBorweinMethod(f, grad_f, init_alpha=1e-4, type=1),
    "BFGS": methods.fo.BFGS(f, grad_f),
    "DFP": methods.fo.DFP(f, grad_f),
    "LBFGS": methods.fo.LBFGS(f, grad_f),
}

In [12]:
for m in method:
    print("\t Method {}".format(m))
    _ = method[m].solve(x0=x0, tol=tol, max_iter=max_iter, disp=True)

print("\t Method BFGS Scipy")
scopt_conv = []
scopt_res = scopt.minimize(f, x0, method="BFGS", jac=grad_f, callback=lambda x: scopt_conv.append(x), 
                           tol=tol, options={"maxiter": max_iter})
print("Result: {}".format(scopt_res.message))
if scopt_res.success:
    print("Convergence in {} iterations".format(scopt_res.nit))
print("Function value = {}".format(f(scopt_res.x)))


	 Method BB 1
Required tolerance achieved!
Convergence in 10 iterations
Function value = -706.3813010885202
Norm of gradient = 5.175932028032216e-06
	 Method BFGS
/Users/alex/anaconda3/envs/cvxpy/lib/python3.6/site-packages/ipykernel_launcher.py:1: RuntimeWarning: invalid value encountered in log
  """Entry point for launching an IPython kernel.
Required tolerance achieved!
Convergence in 24 iterations
Function value = -706.381301088513
Norm of gradient = 6.600457067992006e-06
	 Method DFP
Maximum iteration exceeds!
Convergence in 100 iterations
Function value = -706.3809879723719
Norm of gradient = 0.043235666667093504
	 Method LBFGS
Required tolerance achieved!
Convergence in 8 iterations
Function value = -706.381301088518
Norm of gradient = 5.310855073090763e-06
	 Method BFGS Scipy
Result: Optimization terminated successfully.
Convergence in 15 iterations
Function value = -706.3813010868059

In [13]:
plt.figure(figsize=(8, 6))

for m in method:
    plt.semilogy([np.linalg.norm(grad_f(x)) for x in method[m].get_convergence()], label=m)

plt.semilogy([np.linalg.norm(grad_f(x)) for x in [x0] + scopt_conv], label="BFGS SciPy")
plt.ylabel("$\|f'(x_k)\|_2$", fontsize=18)
plt.xlabel("Number of iterations, $k$", fontsize=18)
plt.legend(fontsize=18)
plt.xticks(fontsize=18)
_ = plt.yticks(fontsize=18)



In [14]:
for m in method:
    print("\t Method {}".format(m))
    %timeit method[m].solve(x0=x0, tol=tol, max_iter=max_iter)

%timeit scopt.minimize(f, x0, method="BFGS", jac=grad_f, tol=tol, options={"maxiter": max_iter})


	 Method BB 1
7.26 ms ± 947 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
	 Method BFGS
/Users/alex/anaconda3/envs/cvxpy/lib/python3.6/site-packages/ipykernel_launcher.py:1: RuntimeWarning: invalid value encountered in log
  """Entry point for launching an IPython kernel.
5.17 s ± 162 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
	 Method DFP
/Users/alex/anaconda3/envs/cvxpy/lib/python3.6/site-packages/ipykernel_launcher.py:1: RuntimeWarning: invalid value encountered in log
  """Entry point for launching an IPython kernel.
9.44 s ± 224 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
	 Method LBFGS
/Users/alex/anaconda3/envs/cvxpy/lib/python3.6/site-packages/ipykernel_launcher.py:1: RuntimeWarning: invalid value encountered in log
  """Entry point for launching an IPython kernel.
31.3 ms ± 1.22 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
14.9 s ± 427 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Плохо обусловленная задача


In [15]:
n = 50
D = np.arange(1, n+1)
U = np.random.randn(n, n)
U, _ = np.linalg.qr(U)
A = U.dot(np.diag(D)).dot(U.T)
b = np.random.randn(n)
eig_vals = np.linalg.eigvals(A)
print("Condition number = {}".format(np.max(eig_vals) / np.min(eig_vals)))


Condition number = 49.99999999999985

In [16]:
f = lambda x: 0.5 * x.T.dot(A.dot(x)) - b.dot(x)
gradf = lambda x: A.dot(x) - b
x0 = np.random.randn(n)

In [39]:
method = {
    "BB 1": methods.fo.BarzilaiBorweinMethod(f, gradf, init_alpha=1e-4, type=1),
    "BB 2": methods.fo.BarzilaiBorweinMethod(f, gradf, init_alpha=1e-4, type=2),
    "BFGS": methods.fo.BFGS(f, gradf),
    "DFP": methods.fo.DFP(f, gradf),
    "GD": methods.fo.GradientDescent(f, gradf, ss.ExactLineSearch4Quad(A, b)),
    "LBFGS": methods.fo.LBFGS(f, gradf, hist_size=10),
}

In [42]:
for m in method:
    print("\t Method {}".format(m))
    _ = method[m].solve(x0=x0, tol=tol, max_iter=max_iter, disp=True)

print("\t Method BFGS Scipy")

scopt_conv = []
scopt_res = scopt.minimize(f, x0, method="BFGS", jac=gradf, callback=lambda x: scopt_conv.append(x), 
                           tol=tol, options={"maxiter": max_iter})
print("Result: {}".format(scopt_res.message))
if scopt_res.success:
    print("Convergence in {} iterations".format(scopt_res.nit))
print("Function value = {}".format(f(scopt_res.x)))

print("\t Method L-BFGS Scipy")

scopt_lbfgs_conv = []
scopt_res = scopt.minimize(f, x0, method="L-BFGS-B", jac=gradf, tol=tol, 
                           options={"maxiter": max_iter, 'maxcor': 10, "ftol": 1e-10, "gtol": 1e-6},
                           callback=lambda x: scopt_lbfgs_conv.append(x),
                           )
print("Result: {}".format(scopt_res.message))
if scopt_res.success:
    print("Convergence in {} iterations".format(scopt_res.nit))
print("Function value = {}".format(f(scopt_res.x)))


	 Method BB 1
Required tolerance achieved!
Convergence in 56 iterations
Function value = -1.778380361346168
Norm of gradient = 7.431186818297491e-06
	 Method BB 2
Required tolerance achieved!
Convergence in 69 iterations
Function value = -1.778380361349663
Norm of gradient = 2.9939117712775654e-06
	 Method BFGS
Required tolerance achieved!
Convergence in 46 iterations
Function value = -1.7783803613475717
Norm of gradient = 7.715897571927681e-06
	 Method DFP
Required tolerance achieved!
Convergence in 94 iterations
Function value = -1.7783803613439129
Norm of gradient = 7.86533795888771e-06
	 Method GD
Maximum iteration exceeds!
Convergence in 100 iterations
Function value = -1.7783483795263157
Norm of gradient = 0.011325539110718038
	 Method LBFGS
Required tolerance achieved!
Convergence in 41 iterations
Function value = -1.7783803613456495
Norm of gradient = 8.244575725838867e-06
	 Method BFGS Scipy
Result: Optimization terminated successfully.
Convergence in 57 iterations
Function value = -1.77838036134815
	 Method L-BFGS Scipy
Result: b'CONVERGENCE: REL_REDUCTION_OF_F_<=_FACTR*EPSMCH'
Convergence in 41 iterations
Function value = -1.7783803613314786

In [43]:
plt.figure(figsize=(12, 8))
fontsize = 26
for m in method:   
    plt.semilogy([np.linalg.norm(gradf(x)) for x in method[m].get_convergence()], label=m)

plt.semilogy([np.linalg.norm(gradf(x)) for x in [x0] + scopt_conv], label='BFGS SciPy')

plt.semilogy([np.linalg.norm(gradf(x)) for x in [x0] + scopt_lbfgs_conv], label='LBFGS SciPy')
plt.legend(fontsize=fontsize)
plt.ylabel("$\|f'(x_k)\|_2$", fontsize=fontsize)
plt.xlabel("Number of iterations, $k$", fontsize=fontsize)
plt.xticks(fontsize=fontsize)
_ = plt.yticks(fontsize=fontsize)



In [45]:
for m in method:
    print("\t Method {}".format(m))
    %timeit method[m].solve(x0=x0, tol=tol, max_iter=max_iter)

%timeit scopt.minimize(f, x0, method="BFGS", jac=gradf, tol=tol, options={"maxiter": max_iter})
%timeit scopt.minimize(f, x0, method="L-BFGS-B", jac=gradf, tol=tol, options={"maxiter": max_iter, 'maxcor': 10, "ftol": 1e-10, "gtol": 1e-6})


	 Method BB 1
893 µs ± 89.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
	 Method BB 2
1.14 ms ± 71.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
	 Method BFGS
3.72 ms ± 94.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
	 Method DFP
7.18 ms ± 490 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
	 Method GD
1.74 ms ± 146 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
	 Method LBFGS
5.01 ms ± 248 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
6.33 ms ± 785 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
922 µs ± 67.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Pro & Contra

Pro:

  1. Вместо точного вычисления гессиана используется его оценка, полученная с помощью градиента и оценки гессиана в предыдущей точке
  2. Вместо решения систем линейных уравнений используется текущаю информация о функции и градиенте для аналитического вычисления приближения обращённого гессиана
  3. Сложность одной итерации $O(n^2) + ...$ по сравнению с $O(n^3) + ...$ в методе Ньютона
  4. Для метода L-BFGS требуется линейное количество памяти по размерности задачи
  5. Свойство самокоррекции метода BFGS: если на некоторой итерации обратный гессиан оценен очень грубо, то следующие несколько итераций улучшат оценку
  6. Сверхлинейная сходимость к решению задачи минимизации $f$ (подробнее см. [1])

Contra:

  1. Нет универсального рецепта выбора начального приближения $B_0$ или $H_0$
  2. Нет разработанной теории сходимости и оптимальности
  3. Не любое условие на линейный поиск шага гарантирует выполнения условия кривизны $y^{\top}_ks_k > 0$