На этом занятии
мы с вами рассмотрим довольно популярный алгоритм под названием «метод
градиентного спуска» или, еще говорят «метод наискорейшего спуска». Идея метода
довольно проста. Предположим, имеется дифференцируемая функция с точкой экстремума x*. Нам нужно
найти эту точку. Для простоты восприятия информации, предположим, что это
парабола с точкой экстремума x*. Конечно, для такого простого случая
эту точку можно очень легко определить из уравнения:
но она
используется лишь для визуализации метода градиентного спуска. В
действительности функции могут быть гораздо сложнее и зависеть от произвольного
числа аргументов, для которых решать системы уравнений достаточно хлопотное
занятие. Или же, функция постоянно меняется и нам необходимо под нее
подстраиваться для определения текущего положения точки минимума. Все эти
задачи удобнее решать с позиции алгоритмов направленного поиска, например,
градиентного спуска.
Итак, из рисунка
мы хорошо видим, что справа от точки экстремума x* производная
положительна, а слева – отрицательна. И это общее математическое правило для
точек локального минимума. Предположим, что мы выбираем произвольную начальную
точку на оси абсцисс. Теперь,
смотрите, чтобы из двигаться
в сторону x*, нам нужно в
области положительных производных ее уменьшать, а в области отрицательных –
увеличивать. Математически это можно записать так:
Здесь n – номер
итерации работы алгоритма. Но, вы можете спросить: а где же тут градиент? В
действительности, мы его уже учли чисто интуитивно, когда определяли
перемещение вдоль оси абсцисс для поиска оптимальной точки x*. Но математика
не терпит такой вульгарности, в ней все должно быть прописано и точно
определено. Как раз для этого нужно брать не просто производную, а еще и
определять направление движения, используя единичные векторы декартовой системы
координат:
И градиент
функции можно записать как
то есть, это
будет уже направление вдоль оси ординат и направлен в сторону наибольшего
увеличения функции. Соответственно, двигаясь в противоположную сторону, будем
перемещаться к точке минимума x*.
Конечно, в результирующей
формуле алгоритма поиска этот единичный вектор не пишется, а вместо него
указывается разность по оси ординат, т.к. именно вдоль нее он и направлен:
Однако у такой
формулы есть один существенный недостаток: значение производной может быть
очень большим и мы попросту «перескочим» через значение x* и уйдем далеко
влево или вправо. Чтобы этого не происходило, производную дополнительно
умножают на некоторое небольшое число λ:
которое, в общем
случае, также может меняться от итерации к итерации. Этот множитель получил
название шаг сходимости.
Давайте, для
примера, реализуем этот алгоритм на Python для случая
одномерной параболы. Вначале подключим необходимые библиотеки и определим две
функции: параболу
и ее
производную:
import time
import numpy as np
import matplotlib.pyplot as plt
def f(x):
return x*x - 5*x + 5
def df(x):
return 2*x – 5
Затем, определим
необходимые параметры алгоритма и сформируем массивы значений по осям абсцисс и
ординат:
N = 20 # число итераций
xx = 0 # начальное значение
lmd = 0.1 # шаг сходимости
x_plt = np.arange(0, 5.0, 0.1)
f_plt = [f(x) for x in x_plt]
Далее, переходим
в интерактивный режим работы с графиком, чтобы создать анимацию для перемещения
точки поиска минимума и создаем окно с осями графика:
plt.ion() # включение интерактивного режима отображения графиков
fig, ax = plt.subplots() # Создание окна и осей для графика
ax.grid(True) # отображение сетки на графике
Отображаем
начальный график:
ax.plot(x_plt, f_plt) # отображение параболы
point = ax.scatter(xx, f(xx), c='red') # отображение точки красным цветом
Запускаем
алгоритм градиентного поиска:
for i in range(N):
xx = xx - lmd*df(xx) # изменение аргумента на текущей итерации
point.set_offsets([xx, f(xx)]) # отображение нового положения точки
# перерисовка графика и задержка на 20 мс
fig.canvas.draw()
fig.canvas.flush_events()
time.sleep(0.02)
В конце выводим
найденное значение аргумента и оставляем график на экране устройства:
plt.ioff() # выключение интерактивного режима отображения графиков
print(xx)
ax.scatter(xx, f(xx), c='blue')
plt.show()
Реализация алгоритма на Python (файл grad1_1.py)
После запуска увидим скатывание точки к экстремуму функции. Установим значение
lmd = 0.9
Увидим
«перескоки» оптимального значение, то есть, неравномерную сходимость к точки
минимума. А вот, если поставить параметр
lmd = 1
то скатывания
совсем не будет, т.к. аргумент x будет
«перескакивать» точку минимума и никогда ее не достигать. Вот так параметр
λ влияет на работу алгоритма градиентного спуска.
Выбор шага сходимости
Важно правильно
его подбирать в каждой конкретной решаемой задаче. Обычно, это делается с
позиции «здравого смысла» и опыта разработчика, но общие рекомендации такие:
начинать со значения 0,1 и уменьшать каждый раз на порядок для выбора лучшего,
который
обеспечивает и скорость и точность подбора аргумента x. В целом, это
элемент творчества и, например, можно встретить и такой вариант изменения шага
сходимости:
Функция min здесь выбирает
наименьшее из двух аргументов и использует его в знаменателе дроби. Это
необходимо, чтобы величина шага при больших n не становилась
слишком маленькой и ограничивалась величиной
где mn – некоторое
заданное ограничивающее значение.
Еще один прием
связан с нормализацией градиента на каждом шаге работы алгоритма. В этом случае
градиент функции (то есть, вектор):
приводится к единичной
норме:
И уже он
используется в алгоритме наискорейшего спуска:
В одномерном
случае нашего примера, это, фактически означает, что вместо действительного
значения градиента, берутся числа ±1:
И алгоритм в Python примет вид:
xx = xx - lmd*np.sign(df(xx)) # изменение аргумента на текущей итерации
Как видите,
такой подход требует уменьшения величины шага сходимости, на последующих
итерациях, например, так:
Получим:
Результат
выглядит уже лучше и при этом, мы не привязаны к значению градиента.
Реализация алгоритма на Python (файл grad1_2.py)
Локальный минимум
Еще одной
особенностью работы этого алгоритма является попадание в область локального
минимума функции. Например, если взять вот такой график:
То при начальном
значении x=0 получим один
минимум, а, например, при x=2,5 – другой. В этом легко убедиться,
если в нашей программе переписать функции:
def f(x):
return np.sin(x)+0.5*x
def df(x):
return np.cos(x) + 0.5
(последняя – это
производная: ). Увеличим
диапазон отображения графика:
x_plt = np.arange(-5.0, 5.0, 0.1)
и запустим
программу. Теперь, установим начальное значение xx=2,5, снова
запустим и увидим уже другую точку локального минимума.
Это, наверное,
основной недостаток данного алгоритма – попадание в локальный минимум. Чтобы
решить эту проблему перебирают несколько разных начальных значений и смотрят:
при котором был достигнуто наименьшее значение. Его и отбирают в качестве
результата. Такой ход не дает нам гарантии, что действительно был найден
глобальный минимум функции (иногда он может не существовать, как, например, с
нашей синусоидой), но, тем не менее, это повышает наш шанс найти лучшее
решение. Вот базовая теория и практика применения метода наискорейшего спуска.