Строим градиентные алгоритмы оптимизации Adam, RMSProp, Adagrad, Adadelta

На этом занятии мы с вами,наконец то, коснемся темы оптимизации с помощью Tensorflow, используя частные производные, то есть, по сути, алгоритм градиентного спуска. Если вы не знаете, как работает градиентный спуск, то смотрите отдельное занятие по этой теме:

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

Мы начнем с решения очень простой задачи. Сгенерируем 1000 точек линейной функции вида:

и добавим к ней гауссовский шум с небольшой дисперсией:

На уровне языка Python и пакета Tensorflow это можно сделать следующим образом:

import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
 
import matplotlib.pyplot as plt
import tensorflow as tf
import numpy as np
 
TOTAL_POINTS = 1000
 
x = tf.random.uniform(shape=[TOTAL_POINTS], minval=0, maxval=10)
noise = tf.random.normal(shape=[TOTAL_POINTS], stddev=0.2)
 
k_true = 0.7
b_true = 2.0
 
y = x * k_true + b_true + noise
 
plt.scatter(x, y, s=2)
plt.show()

При выполнении этого фрагмента программы получим следующий график случайных точек на плоскости:

Наша задача по этим точкам восстановить начальную функцию f, то есть, найти ее параметры k и b. Сделаем это с помощью Tensorflow. Первым делом определим начальные значения этих параметров (пусть они равны нулю):

k = tf.Variable(0.0)
b = tf.Variable(0.0)

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

Здесь - значение точек  по оси ординат; - значение этих же точек по оси Oy, вычисленное на основе параметров. То есть, эту же формулу можно записать и так:

На уровне Tensorflow это можно записать  в виде:

f = k * x + b
loss = tf.reduce_mean(tf.square(y - f))

Далее, в соответствии с градиентным спуском, параметры  изменяются по формулам:

(Здесь η – шаг обучения). Для реализации этого алгоритма зададим число итераций обучения и шаг обучения:

EPOCHS = 500
learning_rate = 0.02

Затем, на каждой итерации цикла обучения будем вычислять градиент от функции потерь loss (используя уже известный нам объект GradientTape) в точке с текущими значениями :

for n in range(EPOCHS):
    with tf.GradientTape() as t:
        f = k * x + b
        loss = tf.reduce_mean(tf.square(y - f))
 
    dk, db = t.gradient(loss, [k, b])
 
    k.assign_sub(learning_rate * dk)
    b.assign_sub(learning_rate * db)

После вычисления производных, мы корректируем параметры k и b в соответствии с формулой градиентного спуска.

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

print(k, b, sep="\n")
 
y_pr = k * x + b
plt.scatter(x, y, s=2)
plt.scatter(x, y_pr, c='r', s=2)
plt.show()

В итоге, получим значения примерно 0,7 и 2, что верно для нашей задачи, а графики выглядят, следующим образом:

Как видите, мы довольно точно аппроксимировали множество точек линейной функцией. В этом и состояла суть обучения – нахождения неизвестных параметров  для минимизации заданной функции потерь loss. Подобный принцип оптимизации положен в основу многих алгоритмов машинного обучения и Tensorflow нам здесь значительно облегчает работу.

Стохастический градиентный спуск

В рассмотренной задаче функция потерь loss была относительно простой – один экстремум, он же локальный минимум. Найти оптимальные значения здесь можно было бы и прямым дифференцированием функции loss, приравнивая частные производные нулю:

Получим систему из двух линейных уравнений для нахождения k и b. К сожалению, в реальных задачах функция loss часто имеет гораздо более сложный вид (с множеством локальных минимумов), и зависящей от тысяч, а то и миллионов параметров. Решать в таких условиях напрямую систему уравнений – вычислительно весьма неэффективный подход. А часто, просто невозможный, так как она может состоять из нелинейных уравнений. Поэтому «рабочей лошадкой» при оптимизации выступает весьма простой алгоритм градиентного спуска. Однако, у него есть один существенный недостаток – застревание в локальных минимумах функции:

Из-за этого мы далеко не всегда можем быть уверенными, что достигли лучшего (оптимального) решения. (Хотя, это недостаток присущ, наверное, всемреальным алгоритмам оптимизации).

Для преодоления этой проблемы были разработаны различные методы улучшения классического алгоритма градиентного спуска. Самый первый шаг – это использование стохастического градиента. Его смысл в следующем. Обычно, в машинном обучении имеется огромный объем обучающей выборки (десятки тысяч и миллионы наблюдений). Классический градиент предполагает вычисление частных производных на каждом шаге по всей этой выборке, то есть:

В частности, в предыдущей задаче мы вначале вычисляли функцию

loss = tf.reduce_mean(tf.square(y - f))

по всем 1000 точкам, а затем, определяли частные производные по k и b. А теперь представьте, что вместо тысячи точек – миллионы. Скорость работы алгоритма сильно замедлится. Чтобы этого избежать, всю выборку делят на подмножества наблюдений – мини-батчи (mini-batch), в пределах которых и вычисляют градиент. В этом и заключается идея стохастического градиентного спуска.

В нашем примере мы можем его реализовать, следующим образом:

BATCH_SIZE = 100
num_steps = TOTAL_POINTS // BATCH_SIZE
 
for n in range(EPOCHS):
    for n_batch in range(num_steps):
        y_batch = y[n_batch * BATCH_SIZE : (n_batch+1) * BATCH_SIZE]
        x_batch = x[n_batch * BATCH_SIZE : (n_batch+1) * BATCH_SIZE]
 
        with tf.GradientTape() as t:
            f = k * x_batch + b
            loss = tf.reduce_mean(tf.square(y_batch - f))
 
        dk, db = t.gradient(loss, [k, b])
 
        k.assign_sub(learning_rate * dk)
        b.assign_sub(learning_rate * db)

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

Конечно, в Tensorflow встроили реализацию стандартного градиентного спуска, которая так и называется:

SGD (Stochastic Gradient Descent)

Чтобы им воспользоваться, вначале (перед циклами) нужно создать оптимизатор с помощью одноименного класса:

opt = tf.optimizers.SGD(learning_rate=0.02)

Дополнительно мы здесь указываем шаг обучения. А, затем, вместо строчек:

k.assign_sub(learning_rate * dk)
b.assign_sub(learning_rate * db)

запишем применение вычисленных градиентов dk и db к параметрам k и b:

opt.apply_gradients(zip([dk, db], [k, b]))

Конечно, разбивку выборки на mini-batch мы должны выполнять сами. Данный оптимизатор лишь применяет стандартный алгоритм градиентного спуска к параметрам k и b.

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

Однако, и стохастический градиентный спускпо-прежнему легко застревает в локальных минимумах функции. Чтобы как то устранить этот недостаток было придумано множество различных оптимизаторов. Я здесь лишь приведу их список и характеристики. Более подробное описание можно поискать на просторах Интернет, а также могу порекомендовать книгу:

Николенко С., Кадурин А., Архангельская Е. Глубокое погружение. — СПб.: Питер, 2018.

(Это не реклама, с авторами я не знаком, но, на мой взгляд, в ней представлен прекрасный обзор современного состояния алгоритмов обучения нейронных сетей, различных нейросетевых структур, обучение с подкреплением и многое другое).

Итак, среди алгоритмов оптимизации градиентного спуска можно выделить следующие основные.

Метод моментов (метод импульсов)

Идея этого алгоритма заключается в придании «инерции» (момента) к вычисляемым градиентам. Это как маховик двигателя, который на малых оборотах сохраняет прежний момент инерции при его вращении. Благодаря этому, есть надежда, что градиентный алгоритм не будет «застревать» в неглубоких ямах, проскальзывая по ним «по инерции».

Математически эта идея реализуется достаточно просто. Мы прежнее значение градиента будем умножать на некий дисконтирующий множитель и прибавлять к нему текущее значение градиента для параметра θ:

В итоге, градиент на шаге tбудет содержать «историю» изменений градиентов на предыдущих шагах, сглаживая его, таким образом.

Для реализации этой идеи на Tensorflow достаточно создать оптимизатор SGD с указанием параметра momentum (который и есть γ), например, так:

opt = tf.optimizers.SGD(momentum=0.5, learning_rate=0.02)

Метод Нестерова

Некоторое развитие этой идеи заключается в вычислении градиента не в самой точке θ, а в следующей точке , тем самым, мы как бы заглядываем немного вперед при вычислении градиента. Это и есть идея метода Нестерова, которая на уровне формул выглядит так:

Чтобы использовать этот подход в Tensorflow достаточно в класс SGD передать еще один параметрnesterov=True:

opt = tf.optimizers.SGD(momentum=0.5, nesterov=True, learning_rate=0.02)

Adagrad

Метод моментов и метод Нестерова учитывают только историю изменения градиента, но никак не связаны с самими оптимизируемыми параметрами. Базовая идея здесь в том, что некоторые параметры могут быстрее достигать своего оптимума, чем другие. И это следовало бы учитывать в градиентном спуске. То есть, те параметры, что близки к оптимуму менять с меньшим шагом, а другие – с большим.

Одним из первых алгоритмов, реализующих эту идею, был алгоритм Adagrad. В нем шаг для параметров зависит от величины их флуктуаций (колебаний): чем они больше, тем меньше шаг. Считается, что это заметно ускоряет сходимость при сильно разреженных данных. Математически его можно записать так. Пусть градиент для i-го параметра, равен:

Тогда обновление i-го параметра запишется в виде:

где  - сумма квадратов градиентов для i-го параметра; - сглаживающее слагаемое, чтобы избежать деления на ноль.

В Tensorflow этот алгоритм можно реализовать через класс оптимизатора Adagrad, например, так:

opt = tf.optimizers.Adagrad(learning_rate=0.1)

Главный недостаток данного алгоритма – постоянное уменьшение шага обучения, так как мы складываем квадраты градиентов и делим на корень квадратный из этой величины. Кроме того, сложно подобрать глобальную скорость обучения η, одинаково хорошо работающую в разных задачах.

Adadelta

Для преодоления этих недостатков был реализован алгоритм оптимизации под названием Adadelta. Базовая идея здесь в сохранении не всей истории по квадратам градиентов, а лишь некоторый небольшой «хвост». Проще всего это реализуется через дисконтирующий множитель, например, так:

(разумеется, ). Кроме того, этот алгоритм «исправляет» масштаб в шаге изменения параметров предыдущего алгоритма Adagrad. Во всем остальном логика его работы совпадает с Adagrad.

В Tensorflow этот алгоритм можно реализовать через класс оптимизатора Adadelta, следующим образом:

opt = tf.optimizers.Adadelta(learning_rate=1.0)

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

RMSProp

Алгоритм RMSPropбыл придуман примерно в то же время, что и алгоритм Adadelta и они очень похожи друг на друга. Основное отличие RMSProp в том, что он вместо хранения истории квадратов по каждому параметру, просто берет корень из среднего квадратов градиентов по всем параметрам:

Для его реализации с помощью Tensorflow используется класс RMSprop, например, так:

opt = tf.optimizers.RMSprop(learning_rate=0.01)

Adam

Наконец, последний метод оптимизации, который мы рассмотрим – это Adam.Он достаточно часто применяется при обучении нейронных сетей и хорошо себя зарекомендовал для поиска минимума функций потерь. Фактически, этот алгоритм является очередной модификацией алгоритма Adagrad, использующий сглаженные версии среднего и среднеквадратического градиентов:

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

opt = tf.optimizers.Adam(learning_rate=0.1)

Рекомендации по выбору оптимизатора

Итак, мы с вами рассмотрели следующие пять оптимизаторов:

  • Метод моментов (моменты Нестерова)
  • Adagrad
  • Adadelta
  • RMSProp
  • Adam

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

Видео по теме