Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11.3 Градиентный спуск

Хотя градиентный спуск (gradient descent) редко применяют в глубоком обучении напрямую, понимание этого метода — ключ к пониманию алгоритма стохастического градиентного спуска из следующего раздела. Например, из-за слишком большой скорости обучения задача оптимизации может расходиться; это явление уже проявляется в градиентном спуске. Аналогично, предобусловливание (preconditioning) — распространённый приём в градиентном спуске, который переносится и на более совершенные алгоритмы. Начнём с простого одномерного случая.

11.3.1 Одномерный градиентный спуск

Почему алгоритм градиентного спуска может оптимизировать целевую функцию? Одномерный градиентный спуск даёт хорошее представление об этом. Рассмотрим класс непрерывно дифференцируемых вещественных функций . С помощью разложения в ряд Тейлора получаем

То есть в первом приближении определяется значением функции и её первой производной в точке . Можно предположить, что при движении в направлении антеградиента на малую величину значение уменьшится. Для простоты выберем фиксированный шаг и положим . Подставив это в разложение Тейлора, получим

Если производная не обращается в нуль, мы можем продолжать разложение, поскольку . Кроме того, мы всегда можем выбрать достаточно малым, чтобы члены более высокого порядка стали пренебрежимо малы. Поэтому

Это означает, что если итеративно обновлять по правилу

то значение функции , скорее всего, будет уменьшаться. Таким образом, в градиентном спуске мы сначала выбираем начальное значение и константу , а затем итеративно обновляем до выполнения условия остановки — например, когда модуль градиента становится достаточно малым или когда число итераций достигает заданного значения.

Покажем, как реализовать градиентный спуск. Для простоты возьмём целевую функцию . Хотя мы знаем, что достигает минимума при , мы всё же используем эту простую функцию, чтобы наблюдать за изменением .

MXNet:

%matplotlib inline
from d2l import mxnet as d2l
from mxnet import np, npx
npx.set_np()

PyTorch:

%matplotlib inline
from d2l import torch as d2l
import numpy as np
import torch

TensorFlow:

%matplotlib inline
from d2l import tensorflow as d2l
import numpy as np
import tensorflow as tf

PaddlePaddle:

%matplotlib inline
from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import paddle
def f(x):  # Целевая функция
    return x ** 2

def f_grad(x):  # Градиент (производная) целевой функции
    return 2 * x

Далее возьмём в качестве начального значения и предположим, что . Применив градиентный спуск к на 10 итераций, мы увидим, что значение в конце концов приблизится к оптимальному решению.

MXNet, PyTorch, TensorFlow:

def gd(eta, f_grad):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x)
        results.append(float(x))
    print(f'эпоха 10, x: {x:f}')
    return results

results = gd(0.2, f_grad)

PaddlePaddle:

def gd(eta, f_grad):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x)
        results.append(float(x))
    print(f'эпоха 10, x: {float(x):f}')
    return results

results = gd(0.2, f_grad)

Процесс оптимизации можно изобразить так.

MXNet, PyTorch, TensorFlow:

def show_trace(results, f):
    n = max(abs(min(results)), abs(max(results)))
    f_line = d2l.arange(-n, n, 0.01)
    d2l.set_figsize()
    d2l.plot([f_line, results], [[f(x) for x in f_line], [
        f(x) for x in results]], 'x', 'f(x)', fmts=['-', '-o'])

show_trace(results, f)

PaddlePaddle:

def show_trace(results, f):
    n = max(abs(min(results)), abs(max(results)))
    f_line = d2l.arange(-n, n, 0.01, dtype='float32')
    d2l.set_figsize()
    d2l.plot([f_line, results], [[f(x) for x in f_line], [
        f(x) for x in results]], 'x', 'f(x)', fmts=['-', '-o'])

show_trace(results, f)

11.3.1.1 Скорость обучения

Скорость обучения (learning rate) определяет, сойдётся ли целевая функция к локальному минимуму и когда это произойдёт. Скорость обучения задаёт разработчик алгоритма. Заметим, что если выбрать слишком маленькую скорость обучения, обновления будут очень медленными, и понадобится больше итераций. Например, рассмотрим ход той же задачи оптимизации при . Как видно ниже, даже за 10 шагов мы всё ещё далеки от оптимального решения.

show_trace(gd(0.05, f_grad), f)

Напротив, если выбрать слишком большую скорость обучения, может оказаться слишком большим для линейного приближения первого порядка. То есть член в формуле (11.3.2) может стать существенным. В этом случае итерации по уже не гарантируют уменьшения . Например, при значение перескакивает оптимальное решение и постепенно расходится.

show_trace(gd(1.1, f_grad), f)

11.3.1.2 Локальные минимумы

Чтобы продемонстрировать градиентный спуск для невыпуклой функции, рассмотрим функцию , где — некоторая константа. Эта функция имеет бесконечно много локальных минимумов. В зависимости от выбранной скорости обучения мы в итоге можем получить лишь одно из многих решений. Следующий пример показывает, как (нереалистично) большая скорость обучения приводит к худшему локальному минимуму.

c = d2l.tensor(0.15 * np.pi)

def f(x):  # Целевая функция
    return x * d2l.cos(c * x)

def f_grad(x):  # Градиент целевой функции
    return d2l.cos(c * x) - c * x * d2l.sin(c * x)

show_trace(gd(2, f_grad), f)

11.3.2 Многомерный градиентный спуск

Теперь, когда одномерный случай понятен, рассмотрим . То есть целевая функция отображает вектор в скаляр. Соответственно, её градиент тоже многомерный: это вектор из частных производных:

Каждый элемент градиента показывает скорость изменения в точке по входу . Как и в одномерном случае, для многомерных функций можно использовать соответствующее разложение Тейлора. А именно,

Иными словами, с точностью до членов второго порядка по направление наибыстрейшего убывания задаёт антеградиент . Выбрав подходящую скорость обучения , получаем типичный алгоритм градиентного спуска:

Как этот алгоритм работает на практике? Рассмотрим целевую функцию с двумерным вектором на входе и скаляром на выходе. Градиент задаётся формулой . Мы будем наблюдать траекторию при градиентном спуске из начальной точки .

Нам понадобятся две вспомогательные функции: первая — это функция update, которую мы применим к начальному значению 20 раз; вторая — функция, которая отображает траекторию .

MXNet, PyTorch, TensorFlow:

def train_2d(trainer, steps=20, f_grad=None):  #@save
    """Оптимизация 2D-целевой функции с помощью пользовательского тренера"""
    # s1 и s2 — внутренние переменные состояния, которые будут использованы позже
    x1, x2, s1, s2 = -5, -2, 0, 0
    results = [(x1, x2)]
    for i in range(steps):
        if f_grad:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
        else:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
        results.append((x1, x2))
    print(f'эпоха {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
    return results

MXNet, TensorFlow:

def show_trace_2d(f, results):  #@save
    """Отображение траектории 2D-переменных в ходе оптимизации"""
    d2l.set_figsize()
    d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
    x1, x2 = d2l.meshgrid(d2l.arange(-5.5, 1.0, 0.1),
                          d2l.arange(-3.0, 1.0, 0.1))
    d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
    d2l.plt.xlabel('x1')
    d2l.plt.ylabel('x2')

PyTorch:

def show_trace_2d(f, results):  #@save
    """Отображение траектории 2D-переменных в ходе оптимизации"""
    d2l.set_figsize()
    d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
    x1, x2 = d2l.meshgrid(d2l.arange(-5.5, 1.0, 0.1),
                          d2l.arange(-3.0, 1.0, 0.1), indexing='ij')
    d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
    d2l.plt.xlabel('x1')
    d2l.plt.ylabel('x2')

PaddlePaddle:

def train_2d(trainer, steps=20, f_grad=None):  #@save
    """Оптимизация 2D-целевой функции с помощью пользовательского тренера"""
    # s1 и s2 — внутренние переменные состояния, которые будут использованы позже
    x1, x2, s1, s2 = -5, -2, 0, 0
    results = [(x1, x2)]
    for i in range(steps):
        if f_grad:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2, f_grad)
        else:
            x1, x2, s1, s2 = trainer(x1, x2, s1, s2)
        results.append((x1, x2))
    print(f'эпоха {i + 1}, x1: {float(x1):f}, x2: {float(x2):f}')
    return results

def show_trace_2d(f, results):  #@save
    """Отображение траектории 2D-переменных в ходе оптимизации"""
    d2l.set_figsize()
    d2l.plt.plot(*zip(*results), '-o', color='#ff7f0e')
    x1, x2 = d2l.meshgrid(d2l.arange(-5.5, 1.0, 0.1, dtype='float32'),
                          d2l.arange(-3.0, 1.0, 0.1, dtype='float32'))
    d2l.plt.contour(x1, x2, f(x1, x2), colors='#1f77b4')
    d2l.plt.xlabel('x1')
    d2l.plt.ylabel('x2')

Далее мы наблюдаем траекторию переменной при скорости обучения . Видно, что после 20 шагов значение приближается к минимуму в точке . Прогресс идёт довольно уверенно, но медленно.

def f_2d(x1, x2):  # Целевая функция
    return x1 ** 2 + 2 * x2 ** 2

def f_2d_grad(x1, x2):  # Градиент целевой функции
    return (2 * x1, 4 * x2)

def gd_2d(x1, x2, s1, s2, f_grad):
    g1, g2 = f_grad(x1, x2)
    return (x1 - eta * g1, x2 - eta * g2, 0, 0)

eta = 0.1
show_trace_2d(f_2d, train_2d(gd_2d, f_grad=f_2d_grad))

11.3.3 Адаптивные методы

Как мы видели в разделе 11.3.1.1, выбрать «правильную» скорость обучения непросто. Если её выбрать слишком маленькой, прогресса почти не будет; если слишком большой — решение начнёт колебаться и может даже расходиться. А что, если бы мы могли определять автоматически или вообще не выбирать скорость обучения? Выход может дать метод второго порядка, который учитывает не только значение и градиент целевой функции, но и её кривизну. Хотя из-за вычислительных затрат такие методы напрямую неприменимы в глубоком обучении, они дают полезную интуицию для построения продвинутых алгоритмов оптимизации, которые могут воспроизводить многие желаемые свойства описанных ниже алгоритмов.

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

Вспомним разложение Тейлора для функции . Фактически его можно записать так:

Чтобы не громоздить обозначения, определим как гессиан ; это матрица размера . Для малых и простых задач вычислить легко. Однако для глубоких нейронных сетей может быть очень большой: хранение элементов обходится дорого, а вычисление через обратное распространение может сделать ситуацию ещё хуже. Тем не менее пока отвлечёмся от этих соображений и посмотрим, какой алгоритм из этого получится.

В конце концов, минимум удовлетворяет условию . Следуя правилам математического анализа из раздела 2.4, возьмём производную от по выражению (11.3.4) и отбросим несущественные члены высшего порядка. В результате получаем

То есть в рамках задачи оптимизации нам нужно обратить гессиан .

Для простого примера имеем и . Поэтому для любого получаем . Иными словами, для идеальной сходимости достаточно одного шага без каких-либо настроек. Здесь нам повезло: разложение Тейлора точное, так как .

Рассмотрим другие задачи. Пусть задана выпуклая функция (гиперболический косинус), где — некоторая константа. Видно, что после нескольких итераций мы получаем глобальный минимум в точке .

c = d2l.tensor(0.5)

def f(x):  # Целевая функция
    return d2l.cosh(c * x)

def f_grad(x):  # Градиент целевой функции
    return c * d2l.sinh(c * x)

def f_hess(x):  # Гессиан целевой функции
    return c**2 * d2l.cosh(c * x)

def newton(eta=1):
    x = 10.0
    results = [x]
    for i in range(10):
        x -= eta * f_grad(x) / f_hess(x)
        results.append(float(x))
    print('эпоха 10, x:', x)
    return results

show_trace(newton(), f)

Теперь рассмотрим невыпуклую функцию, например , где — некоторая константа. Обратите внимание: в методе Ньютона в конце концов мы делим на гессиан. Это значит, что если вторая производная отрицательна, значение может начать расти. Это фатальный недостаток алгоритма! Посмотрим, что происходит на практике.

c = d2l.tensor(0.15 * np.pi)

def f(x):  # Целевая функция
    return x * d2l.cos(c * x)

def f_grad(x):  # Градиент целевой функции
    return d2l.cos(c * x) - c * x * d2l.sin(c * x)

def f_hess(x):  # Гессиан целевой функции
    return - 2 * c * d2l.sin(c * x) - x * c**2 * d2l.cos(c * x)

show_trace(newton(), f)

Результат получился катастрофическим. Как это исправить? Один из способов — заменить гессиан его абсолютным значением; другая стратегия — снова ввести скорость обучения. Это может показаться противоречащим исходной идее, но это не совсем так: знание второго порядка позволяет быть осторожным там, где кривизна велика, и использовать большую скорость обучения там, где целевая функция более пологая. Посмотрим, как это работает при несколько меньшей скорости обучения, например . Как видим, получился довольно эффективный алгоритм.

show_trace(newton(0.5), f)

11.3.3.2 Анализ сходимости

Здесь мы на примере некоторых выпуклых функций проанализируем скорость сходимости метода Ньютона. Эти функции трижды дифференцируемы, а вторая производная не равна нулю, то есть . Поскольку доказательство для многомерного случая является прямым обобщением доказательства для приведённого ниже одномерного случая и не добавляет понимания задачи, мы опускаем многомерный случай.

Обозначим через значение на -й итерации, а через — расстояние до оптимальности на -й итерации. Разложив в ряд Тейлора, получаем, что условие можно записать в виде

где . Разделив это разложение на , получаем

Вспомним предыдущее уравнение обновления . Подставив его и взяв модули обеих частей, получаем

Следовательно, всякий раз, когда мы находимся в ограниченной области, где , ошибка убывает квадратично:

С другой стороны, специалисты по оптимизации называют сходимость «линейной» при условии , а при этом говорят о «постоянной» скорости сходимости. Заметим, что скорость сходимости в целом оценить нельзя, но как только мы оказываемся вблизи минимума, сходимость становится очень быстрой. Кроме того, такой анализ требует, чтобы хорошо вела себя при высоких порядках производных, то есть чтобы не обладала какими-либо «экстремальными» свойствами в том, как быстро меняется её значение.

11.3.3.3 Предобусловливание

Вычислять и хранить полный гессиан очень дорого, и один из способов смягчить эту проблему — «предобусловливание». Оно позволяет обойтись без вычисления всего гессиана и учитывает только его «диагональные» элементы; алгоритм обновляется по формуле:

Это не так точно, как полный метод Ньютона, но всё же гораздо лучше, чем обходиться без него. Почему предобусловливание работает? Представьте, что одна переменная измеряет высоту в миллиметрах, а другая — в километрах. Если оба естественных масштаба выражены в метрах, наша параметризация сильно рассогласована. К счастью, предобусловливание устраняет это несоответствие. Эффективное предобусловливание градиентного спуска равносильно выбору разной скорости обучения для каждой координаты вектора . Мы увидим в одном из следующих разделов, что предобусловливание стимулировало ряд новшеств в алгоритмах стохастического градиентного спуска.

11.3.3.4 Градиентный спуск и линейный поиск

Одна из ключевых проблем градиентного спуска в том, что можно перескочить цель или продвигаться слишком медленно. Простое решение этой проблемы — комбинировать линейный поиск с градиентным спуском. А именно, мы используем направление, заданное , и затем проводим двоичный поиск, чтобы определить такую скорость обучения , при которой принимает минимальное значение.

С точки зрения анализа и доказательств этот алгоритм сходится быстро (см. Boyd and Vandenberghe, 2004). Однако для глубокого обучения он малопригоден, поскольку каждый шаг линейного поиска требует вычисления целевой функции на всём наборе данных, и такой способ реализации слишком дорог.

Резюме

  • Величина скорости обучения важна: слишком большая скорость приводит к расходимости модели, слишком малая — к отсутствию прогресса.
  • Градиентный спуск может застрять в локальном минимуме и не найти глобальный минимум.
  • В моделях высокой размерности настроить скорость обучения сложно.
  • Предобусловливание помогает выравнивать масштабы.
  • В выпуклых задачах метод Ньютона, как только начинает работать корректно, сходится гораздо быстрее.
  • Для невыпуклых задач не следует применять метод Ньютона без дополнительных настроек.

Упражнения

  1. Проведите эксперименты с градиентным спуском при разных скоростях обучения и разных целевых функциях.
  2. Реализуйте линейный поиск на отрезке для минимизации выпуклой функции.
    1. Нужны ли производные для двоичного поиска, то есть для выбора между и ?
    2. Насколько быстро сходится алгоритм?
    3. Реализуйте этот алгоритм и примените его для нахождения минимума .
  3. Постройте целевую функцию, определённую на , для которой градиентный спуск будет очень медленным. Подсказка: масштабы разных координат должны различаться.
  4. Реализуйте облегчённую версию метода Ньютона с предобусловливанием.
    1. Используйте диагональный гессиан в качестве предобусловливателя.
    2. Используйте его абсолютное значение вместо фактического (возможно, со знаком).
    3. Примените это к задаче выше.
  5. Примените описанный алгоритм к нескольким целевым функциям (выпуклым и невыпуклым). Что будет, если повернуть координаты на градусов?

MXNet: Обсуждение

PyTorch: Обсуждение

TensorFlow: Обсуждение

PaddlePaddle: Обсуждение

Литература

  • Boyd, S., & Vandenberghe, L. (2004). Convex Optimization. Cambridge University Press.