Хабрахабр

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

Здесь будет еще одна. В интернете есть много статей с описанием алгоритма градиентного спуска.

Разработанный ВМФ… стоивший 2 миллиона долларов компьютер "704", обучился различать левое и правое после пятидесяти попыток… По утверждению ВМФ, они используют этот принцип, чтобы построить первую мыслящую машину класса "Перцептрон", которая сможет читать и писать; разработку планируется завершить через год, с общей стоимостью $100 000… Ученые предсказывают, что позже Перцептроны смогут распознавать людей и называть их по имени, мгновенно переводить устную и письменную речь с одного языка на другой. 8 июля 1958 года The New York Times писала: «Психолог показывает эмбрион компьютера, разработанного, чтобы читать и становиться мудрее. Николенко, «Глубокое обучение, погружение в мир нейронный сетей»). Мистер Розенблатт сказал, что в принципе возможно построить "мозги", которые смогут воспроизводить самих себя на конвейере и которые будут осознавать свое собственное существование» (цитата и перевод из книги С.

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

Двоичная (бинарная) классификация объектов, искусственный нейрон класса «Перцептрон»

Вот наш искусственный нейрон, он делит объекты на два класса (выполняет двоичную классификацию объектов):

image

Итак, у нас есть:

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

$\hat$ [игрик с крышкой] — будем обозначать предсказанное (вычисленное) значение класса для объекта $x$
$y$ [обычный игрик без крышки] — истинные (известные) значения класса для объекта $x$ из обучающей выборки.

Для обучающей выборки для каждого объекта $x$ известна метка класса $y$. Значения $x$ (здесь и дальше $x$ и $w$ — это не единичные значения, а векторы) меняются от объекта к объекту, весовые коэффициенты $w$ (будучи выбраны один раз) остаются неизменны. Предположение о полезности обученного таким образом нейрона базируется на надежде на то, что с подобранными коэффициентами он будет выдавать правильные значение $\hat{y}$ для новых объектов $x$, истинное значение класса $y$ для которых заранее не известно. На этапе обучения нужно подобрать весовые коэффициенты $w$ так, чтобы модель выдавала правильное значение $\hat{y}$ (совпадающие с $y$) для максимального количество объектов обучающей выборки.

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

Адаптивный линейный нейрон ADALINE

Нейрон ADALINE (adaptive linear neuron) — это обычный искусственный нейрон вот с такой функцией активации:

$Φ(x,w)=Φ(SUM)=SUM$

$\Phi (x^{(i)},w)=\Phi (\sum _{j=1}^{m}w_{j}x_{j}^{(i)})=\sum _{j=1}^{m}w_{j}x_{j}^{(i)}$

Здесь и дальше верхний индекс $i$ в скобках будет обозначать $i$-й элемент обучающей выборки $x^{(i)}$ или истинное значение класса $y^{(i)}$ или предсказанное значение класса $\hat{y}^{(i)}$ для него же.

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

Порог (квантизатор) — предсказывает метку класса:

$" data-tex="display"/> <img src="https://habrastorage.org/getpro/habr/formulas/2a9/172/d8c/2a9172d8c8f8c570a1bf541e59ac074c.svg" alt="$ \hat{y}^{(i)}=\left\{\begin{matrix}1,\Phi (x^{(i)},w)\ge \theta \\-1,\Phi (x^{(i)},w)<\theta \end{matrix}\right.

Если значение активации больше некоторого значения порога θ [тета], то квантизатор назначает объекту метку "1", если значение активации меньше порога θ, то объект получает метку "-1".

Здесь мы можем сформулировать задачу в первом приближении: нам нужно подобрать параметры нейрона

  • весовые коэффициенты $w_j, j=1,..,m$
  • и порог θ [тета]

так, чтобы значения классов $\hat y$, которые нейрон назначает объектам обучающей выборки, совпадали с истинными значениями классов $y$ для этих же элементов (или, по крайней мере, давали правильное значение для большинства).

Немного преобразуем пороговую функцию, возьмем случай для класса $\hat y = 1$ и перенесем порог в левую часть неравенства:

$\begin{gathered} \Phi (x^{(i)},w)\ge \theta \hfill \\\sum _{j=1}^{m}w_{j}x_{j}^{(i)}\ge \theta \hfill \\-\theta +\sum _{j=1}^{m}w_{j}x_{j}^{(i)}\ge 0\hfill \\ \end{gathered}$

обозначим $w_{0}=-\theta$ и $x_{0}=1$

$\begin{gathered} w_{0}x_{0}^{(i)}+\sum _{j=1}^{m}w_{j}x_{j}^{(i)}\ge 0,w_{0}=-\theta ,x_{0}=1\hfill \\\sum _{j=0}^{m}w_{j}x_{j}^{(i)}\ge 0,x_{0}=1\hfill \end{gathered}$

Как видим, нам удалось избавиться от отдельного параметра θ, внеся его под видом нового весового коэффициента $w_0$ под знак суммы, добавив при этом к описанию объекта новый фиктивный единичный признак $x_0=1$.

Скорректируем формулировку задачи с учетом новых обозначений.

Задача': подобрать параметры нейрона — весовые коэффициенты $w_j, j=0,..,m$,
$x_0 = 1$ (признак-константа) — фиктивный нейрон (нейрон смещения)

Про вектор $w$ будем говорить, как про (m+1)-мерный, а не m-мерный. Начиная с этого места нумеруем признаки и весовые коэффициенты c 0, а не с 1. Вектор $x$ в зависимости от контекста можем считать (m+1)-мерным (по большей части в формулах), но при этом помнить, что на самом деле он m-мерный.

Почему он еще и смещения станет понятно позднее. Почему нейрон (в нашем случае, правда, это не нейрон, а признак объекта или просто вход, но в случае многослойной сети он превращается в нейрон и обычно его именно так называют) фиктивный — понятно уже сейчас.

Активация с суммой будут выглядеть теперь так:

$ \Phi (x^{(i)},w)=\Phi (\sum _{j=0}^{m}w_{j}x_{j}^{(i)})=\sum _{j=0}^{m}w_{j}x_{j}^{(i)},x_{0}^{(i)}=1\forall i $

Порог теперь всегда 0 (ноль) (реальное значение переехало в параметр $w_0$):

$" data-tex="display"/> <img src="https://habrastorage.org/getpro/habr/formulas/62e/6cd/a96/62e6cda962586dc9b31f87e92481b481.svg" alt="$ \hat{y}^{(i)}=\left\{\begin{matrix}1,\Phi (x^{(i)},w)\ge 0\\-1,\Phi (x^{(i)},w)<0\end{matrix}\right.

Еще раз сформулируем задачу другими словами (геометрический смысл задачи)

Если мы внимательно посмотрим на формулу функции активации, то мы увидим, что она представляется собой параметрическую гиперплоскость в (m+1)-мерном пространстве, при этом в первых m измерениях она сосуществует вместе с точками элементов выборки, а (m+1)-е измерение — пространство значений функции, отдельное от элементов.

полностью в пространстве значений элементов $x$. Теперь, если мы приравняем значение активации к нулю (значение порога), то это тоже будет гиперплоскость, только уже в m-мерном пространстве, т.е. Эта гиперплоскость будет разделять элементы $x$ на две непересекающиеся группы.

построить m-мерную гиперплоскость в пространстве элементов так, чтобы элементы обучающей выборки с истинным значением класса "1" оказались по одну сторону плоскости, а элементы с истинным классом "-1" — по другую. Обычно в этом месте говорят, что наша задача — подобрать значения параметров $w$, т.е.

Во-вторых, мы так же увидим, что такая постановка задачи хотя и справедлива, но не совсем полна. Для тех, кто не совсем понял, что здесь написано, читайте дальше — сейчас мы все увидим, это во-первых.

Одномерное пространство (m=1)

Все графики строим с обычной библиотекой Matplotlib, но здесь я еще использую библиотеку Seaborn в одной строчке для настройки области графика, т.к. Здесь начнет появляться код. мне нравится, как у нее это получается, но в принципе можно обойтись и без нее.

# coding=utf-8 import matplotlib.pyplot as plt
import seaborn as sns # симпатичная сетка на белом фоне
# (без нее русские подписи -> кракозябры)
sns.set(style='whitegrid', font_scale=1.8)
#sns.set(style='whitegrid') # не будет работать, если включен seaborn
#plt.rcParams.update({'font.size': 16})

Берем множество 1-мерных точек и ответов к ним:

import numpy as np
import math # точки - признаки (одно измерение)
X1 = np.array([1, 2, 6, 8, 10])
# метки классов (правильные ответы)
y = np.array([-1, -1, 1, 1, 1])

Здесь у нас каждый i-й элемент массива X1 — это i-й элемент (i-я точка) обучающей выборки (еще точнее — его 1-й и единственный признак): $x^{(i)}=(X1[i])$, $x^{(i)}_1=X1[i]$

Каждый i-й элемент массива y — правильный ответ, истинная метка, соответствующая i-му элементу обучающей выборки с единственным признаком X1[i].

Возьмем всего 5 точек, первые две отнесем к классу "-1", остальные три — к классу "1".

Нарисуем эти точки на линии:

# ось Ф=0
plt.plot(X1, np.zeros(len(X1)), color='black', lw=2) # обучающие точки на оси Ф=0
plt.scatter(X1[y==1], np.full(len(X1[y==1]), 0), color='blue', marker='o', s=300, label=u'объект x (1 признак): класс-1 (y=1)')
plt.scatter(X1[y==-1], np.full(len(X1[y==-1]), 0), color='red', marker='s', s=300, label=u'объект x (1 признак): класс-2 (y=-1)') plt.xlabel(u'X1 (единственный признак)')
plt.ylabel(u'Ф (активация)') plt.legend(loc='upper left')
plt.show()

Теперь посмотрим на функцию активации:

$\Phi =w_{0}+w_{1}x_{1}$

(m+1)-мерном пространстве): Как видим, это обычная параметрическая прямая на плоскости (в 2-мерном, т.е.

  • на горизонтальной оси у нас лежат точки элементов (они же — значения признака X1)
  • на вертикальной — значения активации для каждого элемента
  • параметр $w_1$ — задает угол наклона,
  • а $w_0$ — сдвиг по вертикальной оси (вот и разгадка к сдвиговому нейрону).

w0 = -1.1
w1 = 0.4 # активация
y_ = w0 + w1*X1 # функция активации (вход для кванзистора - пороговой функции предсказания класса)
plt.plot(X1, y_, color='violet', lw=3, label=u'активация: w0=%0.2f, w1=%0.2f, sse/2=%0.2f'% (w0, w1, sse/2)) # порог: пересечение активации с Ф=0
plt.scatter([-w0/w1], [0], color='violet', marker='o', s=300, label=u'порог активации') # проекция обучающих точек на линию активации
plt.scatter(X1[y==1], y_[y==1], color='lightblue', marker='o', s=200, label=u'активация: класс-1 (y=1)')
plt.scatter(X1[y==-1], y_[y==-1], color='pink', marker='s', s=200, label=u'активация: класс-2 (y=-1)')

Таким образом, если проекция i-го элемента на линию активации оказывается ниже нуля, мы назначаем элементу класс "-1" ($\hat{y} = -1$), если выше нуля, назначаем элементу класс "1" ($\hat{y}=1$). Вспомним еще, что после небольшого преобразования у нас порог активации обратился в ноль.

m-мерном) пространстве признаков. Фиолетовая точка — пересечение линии активации с осью $\Phi=0$, разделяющая элементы из разных классов, — это и есть та самая разделяющая гиперплоскость (для 1-мерного пространства точка — это гиперплоскость), построенная в 1-мерном (т.е. Для того, чтобы назначить элементам классы, нам нужна прямая (2-мерная гиперплоскость) активации, построенная в 2-д (т.е. Как видим, для того, чтобы разделить элементы на группы, её достаточно, но для того, чтобы назначить группам классы — уже не достатоно. от этого зависит, окажутся ли проекции элементов на активацию выше или ниже нуля. в (m+1)-д) пространстве «признаки+активация»: направление отклонения активации от вертикальной оси будет определять класс для групп элементов, т.к.

Нам нужно построить такую линию активации, т.е. Меняя параметры $w_0$ и $w_1$ мы будем получать разные активационные линии. найти такую комбинацию параметров $w$, при которой проекция первых двух точек обучающей выборки на линию активации окажется ниже нуля (для них значение $\hat{y}=y=-1$), а проекция оставшихся 3-х точек окажется выше нуля (для них $\hat{y}=y=1$).

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

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

Добавим на график еще две горизонтальные линии, соответствующие классам {1, -1}, и спроецируем на них элементы.

# горизонтальные линии для меток классов (y=1, y=-1)
plt.plot(X1, np.full(len(X1), 1), color='blue', label=u'метка: класс-1 (y=1)')
plt.plot(X1, np.full(len(X1), -1), color='red', label=u'метка: класс-2 (y=-1)') # обучающие точки на линиях меток классов (y=1, y=-1)
plt.scatter(X1[y==1], np.full(len(X1[y==1]), 1), color='lightblue', marker='o', s=200, label=u'ответ y: класс-1 (y=1)')
plt.scatter(X1[y==-1], np.full(len(X1[y==-1]), -1), color='pink', marker='s', s=200, label=u'ответ y: класс-2 (y=-1)')

Точки с классом "-1" проецируем на нижнюю линию $\Phi=-1$, точки с классом "1" проецируем на верхнюю линию $\Phi=1$.

По вертикальной оси мы строим значения активации, пространство значений активации непрерывно. Обратим здесь внимание на еще один небольшой нюанс. Здесь мы берем дискретное множество классов $y$ и накладываем его на непрерывную шкалу активации $\Phi$ так, что дискретные значения классов становятся обычными точками на шкале активации — частными случаями значений активации, которые она может непосредственно принимать или приближаться к ним достаточно близко. Но результат работы классификатора (функция активации, пропущенная через порог) — дискретное множество из двух элементов {-1, 1}, а не непрерывная шкала. Поэтому и в нашем случае значения классов "-1" и "1" следует воспринимать скорее не как метки классов как есть, а как отображение помеченных классов на шкалу активации. Строго говоря, мы могли бы изначально в качестве классов взять не числовые значения, а строковые метки "класс-1" и "класс-2", в таком случае мы бы должны были поставить в соответствие строковым меткам числовые значения на шкале активации.

Пришло время ввести метрику ошибки

# линии ошибок - расстояния от точек на линии активации
# до горизонтальных линий меток классов
plt.plot([X1, X1], [y_, y], color='orange', lw=3)#, label='err')

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

Функция стоимости (потерь)

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

Вполне очевидно, что, как и в случае с суммой обычных ошибок, чем ближе линия активации находится к точкам с истинными классами элементов, тем меньше будет и сумма квадратичных ошибок, но в случае с квадратичной ошибкой наиболее удаленные элементы будут получать более серьезный штраф. Но можно взять не простую сумму ошибок, а сумму ошибок, возведенных в квадрат (сумма квадратичных ошибок, sum of squared errors, SSE).

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

Итак:

  • Ошибка — расстояние от значения метки класса до гиперплоскости активации
  • SSE — сумма квадратичных ошибок всех элементов обучающей выборки
  • Функция стоимости $J(w)$ — метрика качества для выбранной линии активации. Чем меньше значение стоимости, тем лучше активация.

Возьмем в качестве функции стоимости $1 \over 2$ SSE, в общем случае и для линейного нейрона она будет выглядеть так:

$\begin{gathered} J(w) = {1 \over 2} SSE = {1 \over 2}\sum _{i=1}^{n} (\Phi (\sum _{j=0}^{m}w_{j}x_{j}^{(i)}) - y^{(i)})^{2} ={1 \over 2}\sum _{i=1}^{n} (\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)})^{2} \end{gathered}$

($1 \over 2$ перед SSE, во-первых, не мешает, во-вторых, для удобства — она дальше красиво сократится)

Напомню, что $y^{(i)}$ — истинный класс $i$-го элемента обучающей выборки, т.е. Здесь $i$ — номер элемента, а $n$ — количество элементов в обучающей выборке. заранее известный правильный ответ.

Как мы помним, положение линии активации определяют параметры — весовые коэффициенты $w$, поэтому вектор $w$ выступает в качестве параметра функции потерь.

Для 1-мерного случая

$ J(w)={1 \over 2} SSE = {1 \over 2}\sum_{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}-y^{(i)})^{2} $

Мы подбираем параметры $w$, т.е. Значения $x$ и $y$ заранее известны (это обучающая выборка), поэтому фиксированы. Попробуем построить график, как значение $J(w)$ зависит от параметров $w_0$ и $w_1$ $w_0$ и $w_1$ так, чтобы значение $J(w)$ получилось минимальным.

# наиболее удачный по наглядности диапазон
w0 = np.linspace(-10, 10, 200)
w1 = np.linspace(-1, 1, 200) # https://docs.scipy.org/doc/numpy/reference/generated/numpy.meshgrid.html
# https://stackoverflow.com/questions/36060933/matplotlib-plot-a-plane-and-points-in-3d-simultaneously
ww0, ww1 = np.meshgrid(w0, w1) sse = []
for j in range(len(w1)): sse.append([]) for i in range(len(w0)): sse[j].append(((ww0[j][i]+ww1[j][i]*X1 - y)**2).sum())
sse = np.array(sse) # https://matplotlib.org/mpl_toolkits/mplot3d/tutorial.html
# https://matplotlib.org/api/toolkits/mplot3d.html
from mpl_toolkits.mplot3d import axes3d fig = plt.figure()
ax = fig.add_subplot(111, projection='3d') ax.set_xlabel('w0')
ax.set_ylabel('w1')
ax.set_zlabel('J(w)') #ax.plot_surface(ww0, ww1, sse/2, color='lightblue', rstride=8, cstride=8)
ax.plot_wireframe(ww0, ww1, sse/2, color='lightblue', rstride=8, cstride=8, label='SSE/2') plt.xlim(-10., 10.)
plt.ylim(-1., 1.) plt.legend()
plt.show()

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

#ax.plot_surface(ww0, ww1, np.log(sse/2), color='lightblue', rstride=8, cstride=8)
ax.plot_wireframe(ww0, ww1, np.log(sse/2), color='lightblue', rstride=8, cstride=8, label='log(SSE/2)')


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

Получим значения весовых коэффициентов — получим обученный нейрон. Наша задача — подобрать такие значения $w_0$ и $w_1$, чтобы попасть на дно этой ямы.

Раз уж мы все равно построили график и воочию наблюдаем его минимум, никто нам не запретит найти его координаты простым перебором на сетке "вручную":

# найдем минимальное значение на сетке
# https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.min.html
# https://docs.scipy.org/doc/numpy/reference/generated/numpy.amin.html#numpy.amin
# https://docs.scipy.org/doc/numpy/reference/generated/numpy.argmin.html
min_ind = np.unravel_index(np.argmin(sse), sse.shape)
# точка - минимум
#ax.scatter(ww0[min_ind], ww1[min_ind], sse[min_ind]/2, color='red', marker='o', s=100,
ax.scatter(ww0[min_ind], ww1[min_ind], math.log(sse[min_ind]/2), color='red', marker='o', s=100, label='min: w0=%0.2f, w1=%0.2f, SSE/2=%0.2f' % (ww0[min_ind], ww1[min_ind], sse[min_ind]/2))


26$" data-tex="inline"/> и <img src="https://habrastorage.org/getpro/habr/formulas/5bf/0df/99d/5bf0df99dddd311116eeb262e13c0890.svg" alt="$w_1 = 0. Это значения: <img src="https://habrastorage.org/getpro/habr/formulas/52b/93d/1e5/52b93d1e55c10c92be080b707806499d.svg" alt="$w_0 = -1. 69, функция стоимости $J(w)=SSE/2=0. 27$, сумма квадратичных ошибок SSE при этом 0. 3456478371758288). 35$" data-tex="inline"/> (более точно: 0.

Посмотрим, как с этими параметрами выглядит активация:

# оптимум "вручную" по сетке (SSE=0.69, sse/2=0.345)
w0 = -1.26
w1 = 0.27

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

Перед тем, как двигаться дальше, еще раз полюбуемся на график на сетке пошире:


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

Поиск минимума

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

Поиск минимума: перебор по сетке

  • Вариант на первый взгляд вполне рабочий (как видим)
  • Нужно заранее знать область, где искать минимум (можно взять достаточно большие границы, затем сужать область поиска — это только на глаз)
  • Для повышения точности нужно уменьшать шаг → еще больше точек (решение: можно итеративно сужать область поиска)
  • Слишком много точек (для 2д может и ок, но для многомерных случаев очень быстро упремся в ресурсы)
  • Для MNIST (28x28=784 пикселей — столько же входов, столько же весовых коэффициентов, сетка 100 шагов на размерность): 100^784=10^785.

Это довольно много и для хранения, и для перебора (в видимой части Вселенной всего 10^80 атомов, Вселенная существует примерно 4*10^17 секунд = 4*10^26 наносекунд). Значит, если мы захотим обучить один-единственный нейрон (даже не нейросетку) на изображении 28x28=784 пикселей методом поиска минимума прямым перебором на сетке в 100 точек на каждое измерение, нам нужно перебрать 10^785 комбинаций.

Попробуем найти вариант побыстрее.

Поиск минимума: спуск с постоянным шагом

Посмотрим на график функции потерь $J(w)$ на плоскости: фиксируем $w_0$, меняем $w_1$

def sse_(X, y, w0, w1): return ((w0+w1*X - y)**2).sum() # фиксируем w0, строим график J(w1)=sse(w1)/2
w1 = np.linspace(-1, 1, 200) sse = [[], [], []]
for i in range(len(w1)): sse[0].append(sse_(X1, y, -1, w1[i])) sse[1].append(sse_(X1, y, 0, w1[i])) sse[2].append(sse_(X1, y, 1, w1[i]))
sse = np.array(sse) plt.plot(w1, sse[0]/2, color='orange', label='w0=-1')
plt.plot(w1, sse[1]/2, color='blue', label='w0=0')
plt.plot(w1, sse[2]/2, color='red', label='w0=1') plt.xlabel('w1')
plt.ylabel('J(w)') plt.legend()
plt.show()

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

Рассмотрим вариант с постоянным шагом

  • Если шаг слишком большой, можно промахнуться, так и не подступившись к минимуму (шаг можно уменьшить)
  • Если слишком маленький, будет слишком много шагов (больше, чем могло бы быть)
  • Точный минимум мы в любом случае не достигнем, но можем достигнуть с произвольной точностью, поменяв около найденного неточного минимума шаг (шаг перестает быть постоянным)
  • Не знаем направление спуска (можно решить алгоритмически: не шагать в сторону увеличения ошибки)
  • Проблема с поиском диапазона решена (можно спускаться из любой точки — рано или поздно все равно спустимся вниз)
  • В принципе, вариант рабочий, но может есть вариант получше?

Я сначала ответил что-то в духе, что нам сейчас интересно рассмотреть вариант с итерациями, чтобы потом иметь возможность применить его не только с параболой, но и в других ситуациях. Замечание: когда я рассказывал про такой вариант спуска на лекции, один студент спросил, зачем нужно двигаться по шагам, если можно сразу найти минимум параболы по формуле? Но подумав позднее, я подумал, что в принципе нет ничего плохого, если мы на каждом срезе будем двигаться не шагами, а скатываться сразу к минимуму текущего среза. Плюс к этому, на самом деле нам не нужен минимум параболы конкретно на этом срезе — мы будем двигаться к минимуму не по одному измерению, а по всем измерениям поочередно так, что на каждой новой итерации новый шаг будет проходить не по этой параболе, а на параболе с нового среза со сдвинутым значением $w_0$. Для единственного нейрона должно сработать, и не только с параболой. Так раз за разом измерение за измерением мы должны все равно скатиться к глобальному минимуму, и, похоже, быстрее, чем по шагам. Но я пока не стал тратить время на проверку этой теории, поэтому здесь просто двинемся дальше — я обещал рассказать про градиентный спуск.

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

Используем для подбора шага производную кривой стоимости (здесь не кривая стоимость, а кривая стоимости). В общем так, спускаемся по шагам, но делаем это более умно.

Сейчас разберемся. Это всё хорошо, но причем здесь производная?

Геометрический смысл производной

Здесь будет уместно вспомнить или выяснить, что из себя представляет производная на самом деле. Для меня производная долгое время оставалась набором специальных формул и правил для ее вычисления, плюс что-то про возрастание, убывание и экстремумы.

Производная функции $y(x)$ в данной точке $x_0$ — это предел отношения приращения функции $\Delta y$ к приращению аргумента $\Delta x$ при приращении аргумента $\Delta x$, стремящемся к нулю:

$ y'(x_0) = \lim_{\Delta x \to 0} {\Delta y \over \Delta x}, \Delta y = y(x_0 + \Delta x) - y(x_0) $

Точка $N (x_0+\Delta x, y(x_0 + \Delta x)) = (x_0+\Delta x, y_0 + \Delta y)$ — точка, получаемая приращением аргумента $\Delta x$. На картинке точка $M (x_0, y(x_0)) = (x_0, y_0)$ — точка, в которой мы хотим определить производную. Прямая $MN$ — секущая, проходящая через эти две точки.

Точка $A$ — пересечение секущей $MN$ с горизонтальной осью $y=0$.

Из графика и школьного курса геометрии очевидно, что углы $\angle NMP$ и $\angle MAB$ равны, а значит равны и их тангенсы: Рассмотрим два прямоугольных треугольника: треугольник $\triangle NPM$ с участком секущей $MN$ в качестве гипотенузы и треугольник $\triangle MBA$ с продолжением секущей до оси $y=0$ — отрезком $AM$ в качестве гипотенузы.

$ \tan \angle MAB = \tan \angle NMP = {MB \over AB} = {NP \over MP} = {\Delta y \over \Delta x} $

Треугольник $\triangle MBD$ — прямоугольный треугольник с гипотенузой — участком касетельной, отрезком $MD$. Добавим на картинку: $MD$касательная к исходной кривой в точке $M$, пересекает ось $y=0$ в точке $D$.

Устремляем приращение $\Delta x$ к нулю:

Исходный треугольник $\triangle NPM$ с катетами $\Delta x$ и $\Delta y$ сжимается в точку, но подобный ему треугольник $\triangle MBA$ превращается в треугольник $\triangle MBD$, сохраняя не только макроскопические габариты, но и равенство углов $\angle MAB$ и $\angle NMP$. Точка $N$ съезжает в точку $M$ по функции, точка $A$ ползет в точку $D$, секущая $MN$ превращается в касательную $MD$ с точкой касания $M$.

К счастью, мы можем зафиксировать точный пункт назначения для всех этих движений, используя волшебный математический оператор «предел» $\lim$. Как приращение $\Delta x$, бесконечно приближаясь к нулю, никогда не достигнет нуля, так и точка $N$ никогда не встанет на точное место $M$, точка $A$ не достигнет точки $D$, треугольник $\triangle MBA$ не превратится в $\triangle MBD$.

Предел превращения треугольника $\triangle MBA$ — в точности треугольник $\triangle MBD$, получаем:

$ \lim_{\Delta x \to 0}{\Delta y \over \Delta x} = \lim_{\Delta x \to 0}{\tan \angle NMP} = \lim_{\Delta x \to 0}{\tan \angle MAB} = \lim_{\Delta x \to 0}{MB \over AB} = {MB \over DB} = \tan \angle MDB $

Короче:

$ \lim_{\Delta x \to 0}{\Delta y \over \Delta x} = \tan \angle MDB $

Вспоминая определение производной, как мы ее ввели, получаем:

$ y'(x_0) = \lim_{\Delta x \to 0}{\Delta y \over \Delta x} = \tan \angle MDB $

В этом и заключается её геометрический смысл. И видим, что производная функции в данной точке равна тангенсу угла между касательной к исходной кривой в данной точке и горизонтальной осью координат $y=0$.

Более корректно говорить, что предельное положение секущей при приращении аргумента, стремящемся к нулю, мы будем называть касательной, т.е. Строго говоря, не совсем корректно говорить, что секущая стремится занять положение некоторой заранее известной касательной, которую мы, кстати сказать, добавили на картинку без предварительного определения. Но сути дела это не меняет: производная численно равна тангенсу, тангенс характеризует наклон (кстати, в английском языке касательная — это tangent line, может быть интересно разобраться, кто от кого происходит — касательная от тангенса или тангенс от касательной). мы понятие касательной выводим из определения производной (если вы посмотрите на универсальную формулу касательной, то увидите, что она задается через производную).

В итоге отметим интересные нам свойства производной:

Вот, например, наш случай с параболой:

Две зеленые линии — катеты прямоугольного треугольника с гипотенузой — касательной. Оранжевая линия — исходная функция, красная линия — касательная к исходной функции в точке $x_0$, синяя линия — это производная исходной функции в каждой из точек. Отношение вертикальной зеленой линии к горизонтальной — тангенс острого угла между касательной и горизонтальной осью $y=0$, оно же — абсолютное значение производной в этой точке.

В правой части графика (спуск справа налево, функция возрастает) производная везде больше нуля (геометрически: угол между касательной и осью $y=0$ острый, тангенс острого угла больше нуля). Как видим, чем ближе минимум исходной функции, тем более пологий спуск, тем меньше вертикальная зеленая линия по сравнению с горизонтальной, тем меньше абсолютное значение производной.

Спуск слева направо (исходная функция убывает): все то же самое, только со знаком минус (геометрически: угол между касательной и осью $y=0$ тупой, тангенс тупого угла меньше нуля).

Этот индикатор — производная функции в каждой точке. Значит, подытожим: у нас есть универсальный вычислимый индикатор (метрика), который показывает как «крутизну»/«пологость» спуска, так и его направление в каждой из точек. Ровно это мы сейчас и сделаем. И, раз у нас есть такой индикатор, почему бы нам не использовать его для вычисления динамического шага при спуске к минимуму данной функции?

Частная производная по вектору

Мы уже видели, что, хотя она у нас является многомерной, в каждом из измерений ее можно порезать на параболы. Возвращаемся к нашей функции ошибки $J(w)$.

$ J(w)={1 \over 2} SSE ={1 \over 2}\sum _{i=1}^{n}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)})^{2} $

  • Выбираем $k$-й элемент вектора
  • Считаем, что он изменяется
  • Берем по нему производную
  • При этом остальные элементы считаем константами

$ \begin{gathered}\frac{\partial J(w)}{\partial w_{k}} ={\frac{\partial }{\partial w_{k}}}{1 \over 2}\sum _{i=1}^{n}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)})^{2} ={1 \over 2}\sum _{i=1}^{n}{\frac{\partial }{\partial w_{k}}}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)})^{2} \\={1\over 2}\sum _{i=1}^{n}2(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)}) \frac{\partial }{\partial w_{k}}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)}) \\={1\over 2}2\sum _{i=1}^{n}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)}) \frac{\partial }{\partial w_{k}}((w_{0}x_{0}^{(i)}+...+w_{k}x_{k}^{(i)}+...+w_{m}x_{m}^{(i)}) - y^{(i)}) \\=\sum _{i=1}^{n}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)})x_{k}^{(i)} \end{gathered} $

Все элементы, рядом с которыми нет множителя $w_k$ (обратите внимание, он может прятаться внутри общей суммы), берутся за константы. Кого испугали эти четыре строчки формул, не бойтесь: здесь производная суммы равна сумме производных, производная степенной функции, производная сложной функции, производная константы (равна нулю) и другие правила взятия производной из школьной программы. И еще, кстати, обратите внимание, наша $1/2$ перед SSE сократилась именно здесь.

Получили в общем виде:

$ \begin{gathered}\frac{\partial J(w)}{\partial w_{k}} =\sum _{i=1}^{n}(\sum _{j=0}^{m}w_{j}x_{j}^{(i)} - y^{(i)})x_{k}^{(i)} \end{gathered} $

перевернутой [дельтой]): И вот наш градиент — вектор из частных производных (обозначается символом $\nabla$ [набла], перевернутым треугольником, т.е.

$ \nabla J(w)=(\frac{\partial J(w)}{\partial w_{0}},...,\frac{\partial J(w)}{\partial w_{m}}), w=(w_{0},...,w_{m}) $

Определяем правило шага:

$w:=w+\Delta w, \Delta w=-\eta \nabla J(w)$

Оно же для $k$-го измерения:

$ w_{k}:=w_{k}+\Delta w_{k}, \Delta w_{k}=-\eta \frac{\partial J(w)}{\partial w_{k}} $

Здесь обратим внимание на:

  • минус перед градиентом
  • коэффициент $\eta$ [эта] — коэффициент обучения, некоторая константа

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

Наш 1-мерный случай:

$\Phi(x, w)=w_0+w_1x_1$

Частные производные (элементы градиента):

$ \frac{\partial J(w)}{\partial w_{0}} =\sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}-y^{(i)})x_{0}^{(i)} =\sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}-y^{(i)}) $

$\frac{\partial J(w)}{\partial w_{1}}= \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}-y^{(i)})x_{1}^{(i)}$

Шаги по каждому из измерений:

$\Delta w_{0}=-\eta \frac{\partial J(w)}{\partial w_{0}}=-\eta \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}-y^{(i)})$

$\Delta w_{1}=-\eta \frac{\partial J(w)}{\partial w_{1}}=-\eta \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}-y^{(i)})x_{1}^{(i)}$

Давайте построим графики и спустимся по одному из измерений. Между прочим, у нас уже все готово для спуска.

Фиксируем $w_0=1$, спускаемся к минимуму $J(w_1)$

Сумма квадратичных ошибок для элементов обучающей выборки $X$ с правильными ответами (истинными классами) $y$ при выбранных коэффициентах $w_0$ и $w_1$ (будем дальше ссылаться на эту функцию):

def sse_(X, y, w0, w1): return ((w0+w1*X - y)**2).sum()

5 до 1. Посчитаем ошибки на отрезке $w_1$ от -1. 5.

# наиболее удачный по наглядности вариант w0 = 1 w1 = np.linspace(-1.5, 1.5, 200) # должен быть способ сделать то же самое без цикла с матричной операцией через numpy.dot # https://docs.scipy.org/doc/numpy/reference/generated/numpy.dot.html # но здесь так нагляднее, так что ок и так sse = [] for i in range(len(w1)): sse.append(sse_(X1, y, w0, w1[i])) sse = np.array(sse)

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

plt.subplot(3,1,1) # sse plt.plot(w1, sse/2, color='red', label='w0=1') # спуск - первая точка w1_first = .9 plt.scatter(w1_first, sse_(X1, y, w0, w1_first)/2, color='blue', marker='o', s=100) plt.xlim(-1.2, 1.2) plt.xlabel(u'w1') plt.ylabel(u'J(w1, w0=1)') plt.legend(loc='lower left')

Градиент, а точнее, частная производная ${\delta J(w)} \over {\delta w_1}$ — элемент градиента:

grad_w1 = [] for i in range(len(w1)): grad = ((w0 + w1[i]*X1 - y)*X1).sum() grad_w1.append(grad) plt.subplot(3,1,3) plt.plot(w1, grad_w1, label=u'градиент ∂J(w)/∂w1') plt.xlim(-1.2, 1.2) plt.xlabel(u'w1') plt.ylabel(u'∂J(w)/∂w1') plt.legend(loc='upper left')

шаг зависит от того, из какой точки мы шагаем): Шаги спуска $\Delta w_1(w_1) $ (да, здесь размер шага $\Delta w_1$ зависит от текущего значения $w_1$, т.е.

eta = 0.001 delta_w1 = [] for i in range(len(w1)): grad = ((w0 + w1[i]*X1 - y)*X1).sum() delta = -eta*grad delta_w1.append(delta) plt.subplot(3,1,2) plt.plot(w1, delta_w1, color='orange', label=u'Δw1, η=%s'%eta) plt.xlim(-1.2, 1.2) plt.xlabel(u'w1') plt.ylabel(u'Δw1=-η*∂J(w)/∂w1') plt.legend(loc='upper right')

Все вместе

plt.show()

Здесь видим

  • Наверху: график выпуклой функции, имеется глобальный минимум
  • Внизу: частная производная — показывает степень «крутизны» функции наверху (чем больше производная, тем «круче» функция), знак определяет направление
  • Посередине: шаг для спуска — частная производная со знаком минус (чтобы спускаться в сторону минимума), помноженная на коэффициент $\eta$ [эта] (он же коэффициент обучения), определяющий размер шага

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

Ну давайте уже, наконец, спускаться

Мы договорились взять $w_0=1$, а исходную точку спуска <img src="https://habrastorage.org/getpro/habr/formulas/7c1/3db/ad5/7c13dbad5b92f1253fad61f163405f69.svg" alt="$w_1=0. Исходные веса $w$ — обычно это какие-то случайные не очень большие значения или какие-то константы. Здесь же выберем коэффициент обучения $\eta=0. 9$. 001$" data-tex="inline"/> и количество итераций (количество шагов, количество эпох обучения) пусть будет 12:

# норм спуск за 12-14 эпох eta = 0.001 epochs = 12

Посчитаем значения шагов и значения ошибок на каждом новом шаге:

# спуск по точкам по градиенту w1_epochs = [w1_first] delta_w1_epochs = [] w1_next = w1_first for i in range(epochs): grad = ((w0 + w1_next*X1 - y)*X1).sum() delta = -eta*grad w1_next = w1_next + delta delta_w1_epochs.append(delta) w1_epochs.append(w1_next) # последняя дельта - 0 delta_w1_epochs.append(0) w1_epochs = np.array(w1_epochs) delta_w1_epochs = np.array(delta_w1_epochs) # квадратичные ошибки по эпохам sse_epochs = [] for i in range(len(w1_epochs)): sse_epochs.append(sse_(X1, y, w0, w1_epochs[i])) sse_epochs = np.array(sse_epochs)

Обозначим точки спуска с длинами шагов по $w_1$ на графике $J(w_1, w_0=1)$:

# размер точек на графике - уменьшается от первой эпохи к последней size_epochs = [10 + (250-100)*epoch/epochs for epoch in reversed(range(epochs+1))] plt.scatter(w1_epochs, sse_epochs/2, color='blue', marker='o', s=size_epochs, label=u'спуск по градиенту, η=%s'%eta) # длины шагов по w1 plt.plot([w1_epochs, w1_epochs+delta_w1_epochs], [sse_epochs/2, sse_epochs/2], color='orange')#, label='Δw1')

И те же точки спуска с длинами шагов на графике $\Delta w_1(w_1)$

plt.scatter(w1_epochs, delta_w1_epochs, color='blue', marker='o', s=size_epochs, label=u'спуск по градиенту, η=%s'%eta)
plt.plot([w1_epochs, w1_epochs], [delta_w1_epochs, np.zeros(len(delta_w1_epochs))], color='orange')

В этом месте можно тоже испытать просветление, не такое сильное, как первое, конечно. Да, это он, спуск по градиенту (по одному из его измерений), градиентный спуск.

Процедура спуска: для текущей точки на верхнем графике смотрим длину и направление шага на среднем графике, на верхнем графике откладываем шаг из текущей точки, движемся по линии шага на верхнем графике, «падаем» с нее на график, место падения — новая отправная точка, повторяем процедуру.

  • На всех графиках горизонтальная ось — параметр $w_1$ на одном и том же отрезке, вертикальные оси — разные
  • Движемся справа налево, от самой большой точки $w_1$ к самой маленькой
  • Размер шага и направление — оранжевые отрезки на графике: на верхнем графике горизонтальные, на среднем — вертикальные
  • Длина вертикального отрезка на среднем графике равна длине соответствующего горизонтального отрезка на верхнем графике, но на экране они могут не совпадать — обратите внимание на масштабы по осям
  • На среднем графике отрезки с шагами, расположенные ниже нуля (правая половина), дают отрицательный шаг справа налево, выше нуля (левая половина) — положительный шаг слева направо, пересечение с нулем — строгий минимум функции ошибки
  • (говоря проще и строже, здесь шаг — это просто значение вертикальной координаты на среднем графике).
  • Знак перед шагом на среднем графике полностью соответствует направлению спуска на верхнем графике: в правой половине графика мы спускаемся справа налево — шаг отрицательный, в левой половине графика мы спускаемся слева направо — шаг положительный
  • Откуда мы его берем? Из нижнего графика производной — она попадает на средний график со знаком минус. Вспоминаем про геометрический смысл производной.
  • Посмотрим еще ближе на нижний график производной и средний график с шагом. По горизонтальной оси у них одни и те же значения $w_1$, а по вертикали масштаб существенно отличается. Из геометрического смысла производной мы помним, что значение производной в данной точке можно использовать как метрику «крутости»/«пологости» исходной кривой. Чем круче кривая, тем мы дальше от минимума, тем дальше в этом месте можем шагать. Чем кривая более пологая, тем мы ближе к минимуму, тем короче нужно делать шаги, «сбавить обороты». Но посмотрите на диапазон значений на вертикальной оси нижнего графика с производной, а потом на диапазон на горизонтальной оси на верхнем графике с функцией: для исходной точки $w_1=0.9$ значение производной получается примерно 200, при том, что расстояние до минимума, судя по графику, в районе 1. Это значит, что значение производной как есть мы в качестве шага брать не можем, но мы можем легко отмасштабировать его так, чтобы оно попадало в нужный нам диапазон. Для этого используем специальный коэффициент — тот самый коэффициент обучения $\eta$. В нашем случае нужно подобрать его значение так, чтобы значение производной 200 стало меньше 1. Берем $\eta=0.001$, получаем для точки $w_1=0.9$ длину шага 200*0.001=0.2 (с учетом домножения на -1, точный шаг получается -0.2) — в самый раз.
  • Значение ошибки в первой точке $J(w_1=0.9)=92.43$, в последней точке через 12 эпох (шагов, итераций) $J(w_1=0.03)=8.54$
  • Это не реальный минимум, но мы можем приблизиться к реальному минимуму сколь угодно близко, увеличивая количество эпох

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

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

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

Подбираем темп обучения $\eta$

Подытожим

01$" data-tex="inline"/> Возьмем значение <img src="https://habrastorage.org/getpro/habr/formulas/24b/b4f/a24/24bb4fa24397a51d27c1a1f27e4614dc.svg" alt="$\eta=0.

# алгоритм не сходится
eta = 0.01
epochs = 6

Вертикальные отрезки на среднем графике по длине равны соответствующим горизонтальным отрезкам шагов на верхнем графике, хотя при экранном масштабе это не бросается в глаза. Здесь еще раз обратите внимание на диапазон значений по вертикальной оси на среднем графике и диапазон на горизонтальной оси наверху. Каждый раз забираемся все выше по параболе, т.е. Здесь начинаем движение из точки 3-й справа, дальше перепрыгиваем через минимум в 3-ю слева, потом опять через минимум направо, налево и т.п. Это значит, что алгоритм расходится, нужно выбрать [эту] поменьше. с каждым шагом ошибка возрастает.

Для выбора коэффициента $\eta$ удобно построить график сходимости $J(w)$ при разных $\eta$ по эпохам

# спускаемся по градиенту к минимуму J(w0, w1) одновременно по w0 и w1 # норм спуск за 12-14 эпох eta = 0.001 epochs = 12 # спуск - первая точка # берем начальные w0 и w1 - можно случайные, но возьмем так, # чтобы спуститься за 10-15 эпох # NB: (ответ будет зависеть от знака, а, точнее, от того, # с какой стороны минимума начнется спуск) w0_first = -.9 w1_first = -.9 # спуск по точкам по градиенту w0_epochs = [w0_first] w1_epochs = [w1_first] delta_w0_epochs = [] delta_w1_epochs = [] w0_next = w0_first w1_next = w1_first for i in range(epochs): grad_w0 = (w0_next + w1_next*X1 - y).sum() delta_w0 = -eta*grad_w0 grad_w1 = ((w0_next + w1_next*X1 - y)*X1).sum() delta_w1 = -eta*grad_w1 w0_next = w0_next + delta_w0 w1_next = w1_next + delta_w1 delta_w0_epochs.append(delta_w0) delta_w1_epochs.append(delta_w1) w0_epochs.append(w0_next) w1_epochs.append(w1_next) # квадратичные ошибки по эпохам sse_epochs = [] for i in range(len(w1_epochs)): sse = sse_(X1, y, w0_epochs[i], w1_epochs[i]) sse_epochs.append(sse) print('epoch=%d, w0=%f, w1=%f, SSE/2=%f' % (i, w0_epochs[i], w1_epochs[i], sse/2)) sse_epochs = np.array(sse_epochs) # график - ошибка от эпохи при выбранном η (-эта-) plt.plot(range(len(sse_epochs)), sse_epochs, label=u'J(w)=SSE/2, η=%s'%eta) plt.xlabel(u'epoch (η=%s)'%eta) plt.ylabel(u'J(w)') plt.legend(loc='upper right') plt.show()

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

Эпох можно взять чуть побольше с запасом:

# пятьдесят попыток
eta = 0.001
epochs = 50

А вот так расходимся:

# алгоритм не сходится
eta = 0.01
epochs = 8

Ошибка растет.

Подбираем количество эпох

Идеальный минимум получить не можем, но можем приблизиться к нему настолько близко, насколько захотим. Алгоритм должен успеть спуститься вниз при заданном темпе обучения $\eta$.

Здесь темп ок, эпох маловато.

На практике количество эпох удобнее выбрать одним из двух способов:

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

Если, например, первоначальные веса задаются случайно, мы будем каждый раз получать разные значения ошибки через одно и то же количество эпох. Кстати, ответ (конкретное значение ошибки) будет зависеть от исходных значений весов $w$, в том числе от того, с какой стороны минимума начнется спуск. Нас интересует не точное значение ошибки, а то, насколько близко к минимуму оно приблизилось.

Воронка потерь, одеяло потерь

Вернемся к графику функции потерь, понаблюдаем спуск воочию.

Начальная позиция, начинаем спуск:

Первый шаг — движемся в сторону воронки:

Через 12 эпох — глубоко внутри воронки, но не на самом дне:

Через 50 эпох:

Через 1767 эпох — получаем первый раз ошибку меньше, чем поиск по сетке:

Это фактически и было дно, через 62000 эпох результат лучше не сильно:

По шагам:

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

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

На самом деле поверхность спуска выглядит так: А просто вспомним, что на этом графике вертикальная шкала у нас логарифмическая.

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

Активация на резиночках

С нее начинали, к ней вернулись. Посмотрим еще на функцию активации.

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

Первый шаг — ошибки потихоньку начинают ужиматься:

К 11-му шагу активация заметно уехала наверх и сменила направление наклона: двум синим точкам теперь назначает правильный класс, но для одной синей точки класс все еще неправильный; правильный класс для красных точек сохранился:

12-й шаг: классы для всех точек назначены правильно, но есть ощущение, что можно лучше:

50-я попытка: лучше, чем 12-я

Функция ошибки на этой эпохе $J(w)=0. Шаг 1766: вот линия активации лежит уже довольно красиво. 3456478372$: разницу видно только в 6-м знаке, причем, если до него не округлять) 3456480221$" data-tex="inline"/> — это все еще больше, чем ошибка, найденная вручную по сетке (там была <img src="https://habrastorage.org/getpro/habr/formulas/19e/fc6/726/19efc67263ba609d81e8c228bd329de7.svg" alt="$J(w)=0.

34564503$" data-tex="inline"/> — это лучше, чем ручной результат (разница в 6-м знаке, можно с округлением). Шаг 1767: ошибка $J(w)=0. 184831$, <img src="https://habrastorage.org/getpro/habr/formulas/6cd/404/1f1/6cd4041f15a2c33dd7d43fc0a9cfab87.svg" alt="$w_1=0. Искомые коэффициенты $w_0=-1. 27$, $w_1=0. 258455$ ($w_0$ заметно отличается от посеточных вариантов в пределах 2-х знаков: $w_0=-1. 26$)

3445945$" data-tex="inline"/> — получше, но совсем не радикально (в 2-х знаках после запятой разницы не видно). Шаг 62000: ошибка <img src="https://habrastorage.org/getpro/habr/formulas/329/f16/3c1/329f163c10c3d0553f071a7960b5450f.svg" alt="$J(w)=0. По внешнему виду графики почти не отличить:

в динамике:

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

Подытожим

  • При $\eta=0.001$ на графике сходимости видно, что алгоритм сходится к 10-12-й эпохе (значение ошибки перестает уменьшаться сильно)
  • Но, чтобы получить такую же точность, как при ручном поиске минимума по сетке, с таким же коэффициентом нужно отработать почти две тысячи (1767) эпох
  • Еще дальше гонять обучение не имеет смысла — ошибка почти не уменьшилась спустя еще 60 тыс эпох
  • Нужна ли такая точность или можно довольствоваться еще меньшим — вопрос задачи

184831$" data-tex="inline"/>, <img src="https://habrastorage.org/getpro/habr/formulas/6cd/404/1f1/6cd4041f15a2c33dd7d43fc0a9cfab87.svg" alt="$w_1=0. Результат обучения — нашли коэффициенты (при условии, что остановились на эпохе 1767): $w_0=-1. 258455$.

Протестируем наш обученный нейрон.

4)$" data-tex="inline"/> с заранее неизвестным классом (будем считать, что $t^{(i)}$ — это объекты тестовой выборки). Возьмем объект <img src="https://habrastorage.org/getpro/habr/formulas/aae/db4/4da/aaedb44da74acee7fd60f6bd59b28423.svg" alt="$t^{(1)}=(t_1^{(1)})=(1. он находится между двумя красными точками из обучающей выборки, будем ожидать, что обученный нейрон назначит ему класс $\hat y=-1$, т.е. Но, т.к. отнесет его к группе красных точек слева.

взвешенная сумма

18 + 0. $ SUM=w_0 + w_1*t_1^{(1)} = -1. 4=-0. 26*1. 816 $

активация

816 $" data-tex="display"/> <img src="https://habrastorage.org/getpro/habr/formulas/150/4f0/3da/1504f03daac657d179cf23c27ede83b5.svg" alt="$ \Phi(SUM)=SUM=-0.

квантизатор

816 < 0 \implies \hat y = -1 $" data-tex="display"/> <img src="https://habrastorage.org/getpro/habr/formulas/5ca/4f2/b80/5ca4f2b80c67c95f94eaba41ca70190c.svg" alt="$ \Phi(SUM)=-0.

Как видим, наши ожидания оправдались.

Попробуем еще один вариант: $t^{(2)}=(t_1^{(2)})=(7)$

18 + 0. $ \Phi(SUM)=SUM= -1. 64 \geqslant 0 \implies \hat y = 1 $ 26*7 = 0.

отнес ее к группе синих точек справа. Это точка находилась между двумя синими точками справа и нейрон назначил ей класс $\hat y = 1$, т.е. Наше мнение и мнение нейрона совпало и в этот раз.

Как по мне, отличный результат! Между прочим, мы с вами только что научили искусственный нейрон (так же известный как мыслящая машина класса «Перцептрон») отличать красные точки слева от синих точек справа и нам потребовалось на это всего 12 попыток.

Двумерное пространство (m=2)

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

Каждый элемент принадлежит к одному из 2-х классов. Элементы выборки — точки на плоскости (две координаты).

  • $x = (x_1, x_2)$ (два признака, а не один, как раньше)
  • $y = \{-1, 1\}$ (как и раньше, классов два)

# точки - признаки (два измерения)
X1 = np.array([2, 3, 1, 5, 10, 1, 6, 7, 10, 6, 7])
X2 = np.array([1, 1, 2, 2, 3, 5, 6, 6, 7, 8, 8])
# метки классов - правильные ответы
y = np.array([-1, -1, -1, -1, 1, -1, 1, 1, 1, 1, 1])

plt.scatter(X1[y == -1], X2[y == -1], s=400, c='red', marker='*', label=u'класс: -1')
plt.scatter(X1[y == 1], X2[y == 1], s=200, c='blue', marker='s', label=u'класс: 1') # весовые коэффициенты модели
# вручную - более-менее наглядно
w0 = -2.7
w1 = .3
w2 = .4 # разделяющая линия (порог активации) - пересечение плоскости активации и плоскости Ф=0:
# 0=w0+w1*X1+w2*X2
# X2=-(w0+w1*X1)/w2
plt.plot(np.linspace(0,12), -(w0+w1*np.linspace(0,12))/w2, label=u'порог активации') plt.xlim(0, 11)
plt.ylim(0, 9)
plt.legend(loc='upper left') plt.xlabel('X1')
plt.ylabel('X2') plt.show()

Здесь могли быть длина и ширина чашелистика ириса, но это просто несколько удобных точек.

Активация

$ \Phi(x, w) = w_0 + w_1 x_1 + w_2 x_2 $

Видим, что у нас активация — это параметрическая плоскость в трёхмерном пространстве, значит мы не можем нарисовать ее на плоскости, как для 1-мерного случая, нужно переходить в 3-д:

Плоскость активации с проекциями элементов:

Плоскости классов с проекциями элементов:

Ошибка для каждого элемента — расстояние от проекции элемента на плоскость активации до проекции этого же элемента на плоскость истинного класса:

Вот она же на двумерном графике с точками элементов: Обратите внимание на линию пересечения плоскости активации (сиреневая) и нулевой плоскости $\Phi(w) = 0$ (матово-белая).

Это правда, но это не вся правда. В статьях, описывающих градиентный спуск или рассказывающих про нейросети, обычно рисуют такую линию и говорят, что вот ее нам нужно построить, конечно, если множество еще линейно разделимо (оно линейно разделимо, если группы элементов можно разделить вот такой линией). На самом деле, как мы только что увидели, нам нужно построить не линию в пространстве m=2, а плоскость в пространстве (m+1)=3: пересечение плоскости активации с нулевой координатой активации и даст нам эту линию, хотя все равно одной линии не достаточно — она разделит элементы на две части, но значения классов назначает не линия, а плоскость — в зависимости от того, выше или ниже нуля оказывается проекция элемента на ней (см квантизатор).

Ошибка

$ J(w)={1 \over 2} SSE = {1 \over 2}\sum_{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)} + w_{2}x_{2}^{(i)} - y^{(i)})^{2} $

Кому интересно, попробуйте сделать отдельные 2-д или 3-д срезы или придумать что-нибудь с цветовым кодированием нескольких поверхностей на одном 3д-графике, добавить динамику, или воспользоваться какими-нибудь другим приемом изображения 4-мерных поверхностей в 3-д, если вы такие знаете и если они есть. Красивую воронку функции потерь (стоимости) мы здесь нарисовать уже не можем, т.к., как видим, у нас исходных параметра 3 + значение функции — получается 4 измерения.

Для элементов с большим количеством признаков у впадины будет еще больше измерений, но в нее все равно получится спуститься по градиенту, как в 1-д и как в 2-д. И это уже для простейшего случая для элементов с 2-мя признаками.

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

Градиент

$ \nabla J(w)=(\frac{\partial J(w)}{\partial w_{0}}, \frac{\partial J(w)}{\partial w_{1}}, \frac{\partial J(w)}{\partial w_{1}}), w=(w_{0}, w_{1}, w_{2}) $

Частные производные (элементы градиента):

$ \frac{\partial J(w)}{\partial w_{0}} =\sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}+w_{2}x_{2}^{(i)}-y^{(i)}) $

$ \frac{\partial J(w)}{\partial w_{1}}= \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}+w_{2}x_{2}^{(i)}-y^{(i)})x_{1}^{(i)} $

$ \frac{\partial J(w)}{\partial w_{2}}= \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}+w_{2}x_{2}^{(i)}-y^{(i)})x_{2}^{(i)} $

Шаги по каждому из измерений:

$ \Delta w_{0}=-\eta \frac{\partial J(w)}{\partial w_{0}} =-\eta \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}+w_{2}x_{2}^{(i)}-y^{(i)}) $

$ \Delta w_{1}=-\eta \frac{\partial J(w)}{\partial w_{1}} =-\eta \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}+w_{2}x_{2}^{(i)}-y^{(i)})x_{1}^{(i)} $

$ \Delta w_{2}=-\eta \frac{\partial J(w)}{\partial w_{2}} =-\eta \sum _{i=1}^{n}(w_{0}+w_{1}x_{1}^{(i)}+w_{2}x_{2}^{(i)}-y^{(i)})x_{2}^{(i)} $

001$" data-tex="inline"/>, начальные значения небольшие фиксированные <img src="https://habrastorage.org/getpro/habr/formulas/780/2b6/cc6/7802b6cc64cd5d2d73aa47e3c0d32c8b.svg" alt="$w_0=-0. Спускаемся одновременно по 3-м параметрам (градиент из 3-х измерений), коэффициент обучения $\eta=0. 9$, $w_2=-0. 9$, $w_1=-0. 9$.

Начальная позиция — плоскость активации, зацепившись уголком за вертикальную ось на краю видимой области, свисает далеко вниз:


Уже на первом шаге стремительно подлетает вверх:

Крышка захлопнулась:

К 3-му шагу линия пересечения активации с нулевой плоскостью элементов уже проходит плюс-минус сквозь группы элементов:

На 4-м шаге она назначает неправильный класс только одному красному элементу:

На 60-м шаге положение еще не поменялось — плоскость активации постепенно доворачивается, но с неправильным классом остался все тот же один красный элемент:

Примерно к 70-му шагу он, наконец, тоже попал в свою группу:

Шаг 200-й — все элементы по своим группам с некоторым запасом:

Шаг 400-й — радикальных изменений уже нет:

В динамике:

Здесь интересно наблюдать, как плоскость активации стремительно подлетает наверх по всем параметрам одновременно, а потом начинает всё медленнее доворачиваться почти только по $w_0$.

Код

Поэтому для построения красивых трёхмерных графиков выше мы воспользовались замечательной библиотекой Mayavi. Библиотека matplotlib (её расширение mpl_toolkits.mplot3d.axis3d) не умеет в пересекающиеся поверхности (все приведенные выше графики можно построить, но они будут выглядеть, как плохо читабельная каша в антураже 3д).

import numpy
from mayavi import mlab # не показывать окно - для режима mlab.savefig
#mlab.options.offscreen = True # здесь нужен size для нормальной работы mlab.savefig
fig = mlab.figure(fgcolor=(10./256., 10./256., 10./256.), bgcolor=(255./256., 255./256., 255./256.), size=(1650, 950)) X1_ = range(0, 12)
X2_ = range(0, 12)
XX1_, XX2_ = np.mgrid[X1_, X2_] # Палитра из Блендера
# оранжевый: color=(255./256., 191./256., 71./256.)
# бордовый: color=(171./256., 0./256., 130./256.)
# красный: color=(255./256., 101./256., 107./256.)
# розовый: color=(252./256., 79./256., 245./256.)
# голубой: color=(84./256., 148./256., 247./256.)
# фиолетовый: color=(45./256., 0./256., 82./256.)
# желтый: color=(254./256., 255./256., 87./256.) # Яркие
# красный: color=(.7, .1, .1)
# синий: color=(.1, .1, .7) # горизонтальные плоскости меток: Ф=1 (y=1), Ф=-1 (y=-1) и нулевая плоскость Ф=0
mlab.surf(XX1_, XX2_, np.full((12, 12), -1), color=(255./256., 101./256., 107./256.), opacity=0.6)
mlab.surf(XX1_, XX2_, np.full((12, 12), 1), color=(84./256., 148./256., 247./256.), opacity=0.5)
mlab.surf(XX1_, XX2_, np.full((12, 12), 0), color=(247./256., 243./256., 246./256.), opacity=0.5) # точки на нулевой плоскости
# (их тоже нужно нарисовать, т.к. искомая плоскость должна разделить именно их, а не
# их проекции на плоскостях меток: мы работаем в 2-мерном пространстве, 3е измерение -
# измерение функции активации, включаещее метки классов)
mlab.points3d(X1[y == -1], X2[y == -1], np.full(X1[y == -1].size, 0), color=(.7, .1, .1), mode='sphere', scale_factor=.2)
mlab.points3d(X1[y == 1], X2[y == 1], np.full(X1[y == 1].size, 0), color=(.1, .1, .7), mode='cube', scale_factor=.2) # точки на плоскостях меток
mlab.points3d(X1[y == -1], X2[y == -1], np.full(X1[y == -1].size, -1), color=(171./256., 0./256., 130./256.), mode='2dcircle', scale_factor=.2)
mlab.points3d(X1[y == 1], X2[y == 1], np.full(X1[y == 1].size, 1), color=(45./256., 0./256., 82./256.), mode='2dsquare', scale_factor=.2) # весовые коэффициенты модели
# Спуск по градиенту по эпохам
# ...
epoch=12
w0=-0.762718
w1=0.165023
w2=0.040271
sse=3.598883 # активация - разделяющая плоскость y=w0+w1*X1+w2*X2
yy_ = w0 + w1*XX1_ + w2*XX2_ actsurf = mlab.surf(XX1_, XX2_, yy_, color=(252./256., 79./256., 245./256.), opacity = 0.6) # проекции обучающих точек на плоскость активации
y_ = w0 + w1*X1 + w2*X2 mlab.points3d(X1[y==-1], X2[y==-1], y_[y==-1], color=(171./256., 0./256., 130./256.), mode='sphere', scale_factor=.2)
mlab.points3d(X1[y==1], X2[y==1], y_[y==1], color=(45./256., 0./256., 82./256.), mode='cube', scale_factor=.2) # линии ошибок - расстояния от точек на плоскости активации
# до горизонтальных плоскостей меток классов
for i in range(len(X1[y==-1])): mlab.plot3d( [X1[y==-1][i], X1[y==-1][i]], [X2[y==-1][i], X2[y==-1][i]], [y[y==-1][i], y_[y==-1][i]], color=(255./256., 191./256., 71./256.)) for i in range(len(X1[y==1])): mlab.plot3d( [X1[y==1][i], X1[y==1][i]], [X2[y==1][i], X2[y==1][i]], [y[y==1][i], y_[y==1][i]], color=(255./256., 191./256., 71./256.)) # область видимости - на нее будем насаживать оси координат
# (координатам обязательно нужен объект на рабочем поле)
zmin=-2.
zmax=2.
vis_area = mlab.points3d( [np.min(X1_), np.max(X1_)], [np.min(X2_), np.max(X2_)], [zmin, zmax], mode='point') # установить камеру с центром вращения в геометрическом центре графика
mlab.view( focalpoint=((np.max(X1_)-np.min(X1_))/2, (np.max(X2_)-np.min(X2_))/2, (zmax-zmin)/2), distance=25, azimuth=-50, elevation=75)
mlab.move((0,0,10)) # без этого у пересекающихся поверхностей не будет видно линий пересечений
fig.scene.renderer.use_depth_peeling = 1 # оси
mlab.outline(vis_area, color=(.7, .7, .7))
# здесь пять меток, чтобы было аккуратно на вертикальной оси: -2, -1, 0, 1, 2
axes = mlab.axes(vis_area, nb_labels=5, color=(.7, .7, .7), ranges=[np.min(X1_), np.max(X1_), np.min(X2_), np.max(X2_), zmin, zmax], #xlabel=u'X1', ylabel=u'X2', zlabel=u'Ф(SUM) - активация') xlabel=u'X1', ylabel=u'X2', zlabel=u'Phi') # юникод здесь тоже не работает #from pprint import pprint
#pprint(vars(axes))
axes._label_text_property.bold = False
axes._label_text_property.italic = False
axes._title_text_property.bold = True
axes._title_text_property.italic = False
# свойство есть, но оно не работает
#axes._title_text_property.font_size = 34
# размер шрифтов для шкалы и меток осей регулируем так:
axes.axes.font_factor = .7 # заголовок раскосопиздит при масштабировании с параметром size в mlab.figure,
# если не задать ему здесь вручную размер
title = mlab.title("epoch=" + str(epoch))
title.actor.text_scale_mode='none'
title.property.justification='right'
title.property.font_size=48 legend = mlab.text(.6, .8, 'w0=%0.2f, w1=%0.2f, w2=%0.2f, sse/2=%0.6f'%(w0, w1, w2, sse/2))
legend.actor.text_scale_mode='none'
legend.property.font_size=18 # показать окно
mlab.show() # сохранить в файл
#mlab.savefig("epoch" + str(epoch) + ".png") # для вызова в цикле: дадим файлу сохраниться перед тем, как очищать ресурсы
# (так делать не очень красиво, правильнее, конечно, не убивать,
# а очищать рабочую область, но здесь ок и так)
#mlab.clf()
#mlab.close() # вращение вокруг центра - посмотреть активацию со всех сторон '''
fpoint = ( (np.max(X1_)-np.min(X1_))/2, (np.max(X2_)-np.min(X2_))/2, (zmax-zmin)/2 )
for i in range (0, 360, 2): mlab.view(focalpoint=fpoint, distance=25, elevation=75, azimuth=i) mlab.move((0,0,10)) mlab.savefig("act-2d-azimuth" + str(i) + ".png") '''

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

Однако, он будет работать нормально (рисовать пересекающиеся плоскости) только в том случае, если использовать с ним бэкэнд для рендера на основе Qt. Mayavi, в отличие от Matplotlib/axes3d, использует на 3д-сцене полноценный OpenGL. Я установил через pip пакеты PyQt5 и python-qt (возможно, еще какие-то пакеты, содержащие в имени строку 'qt'). По умолчанию этот бэкэнд вместе с mayavi не устанавливается и не включается. Честно, не знаю, какой из них сработал, но в конечном итоге у меня получилось успешно запустить скрипт, корректно рисующий пересекающиеся плоскости, вот так:

env QT_API=pyqt python3 gradient-2d.py

Сходимость по эпохам — график спуска стоимости $J(w)$ вниз по шагам

def sse_(X1, X2, y, w0, w1, w2): return ((w0+w1*X1+w2*X2 - y)**2).sum() # спускаемся по градиенту к минимуму J(w0, w1, w2)
# одновременно по w0, w1 и w2 # норм спуск
eta = 0.001 # у всех точек правильные классы (впритык)
epochs = 70 w0_first = -.9
w1_first = -.9
w2_first = -.9 # спуск по точкам по градиенту
w0_epochs = [w0_first]
w1_epochs = [w1_first]
w2_epochs = [w2_first]
delta_w0_epochs = []
delta_w1_epochs = []
delta_w2_epochs = []
w0_next = w0_first
w1_next = w1_first
w2_next = w2_first
for i in range(epochs): grad_w0 = (w0_next + w1_next*X1 + w2_next*X2 - y).sum() delta_w0 = -eta*grad_w0 grad_w1 = ((w0_next + w1_next*X1 + w2_next*X2 - y)*X1).sum() delta_w1 = -eta*grad_w1 grad_w2 = ((w0_next + w1_next*X1 + w2_next*X2 - y)*X2).sum() delta_w2 = -eta*grad_w2 w0_next = w0_next + delta_w0 w1_next = w1_next + delta_w1 w2_next = w2_next + delta_w2 delta_w0_epochs.append(delta_w0) delta_w1_epochs.append(delta_w1) delta_w2_epochs.append(delta_w2) w0_epochs.append(w0_next) w1_epochs.append(w1_next) w2_epochs.append(w2_next) # квадратичные ошибки по эпохам
sse_epochs = []
for i in range(len(w1_epochs)): sse = sse_(X1, X2, y, w0_epochs[i], w1_epochs[i], w2_epochs[i]) sse_epochs.append(sse) #print('epoch=%d, w0=%f, w1=%f, w2=%f, SSE=%f, SSE/2=%f' % # (i, w0_epochs[i], w1_epochs[i], w2_epochs[i], sse, sse/2)) sse_epochs = np.array(sse_epochs) # график - ошибка от эпохи при выбранном η (-эта-)
plt.plot(range(len(sse_epochs)), sse_epochs, label=u'J(w)=SSE/2, η=%s'%eta) plt.xlabel(u'epoch (η=%s)'%eta)
plt.ylabel(u'J(w)')
plt.legend(loc='upper right')
plt.show()

12 эпох:

70 эпох:

Здесь отметим, что из этих графиков уже не очень понятно, в какой момент нужно останавливаться: ошибка радикально падает уже к 6-12-й эпохе, а полностью корректная классификация всех точек обучающей выборки наступает только в районе 70-го шага — почему стоит остановиться именно на 70-м шаге, а не на 30-м, 40-м или 200-м, этот график, по крайней мере, в представленном виде, ответ не дает.

Заключение

Разработчики библиотеки scikit-learn не хотят включать алгоритм ADALINE (вариант в веб-архиве для тех, у кого роскомнадзор) в свою библиотеку, ссылаясь на то, что они предпочитают не тратить время и силы на поддержку ретро-алгоритмов «из 80-х» (ADALINE из 60-х), которые для решения современных практических задач больше никому не нужны. Архитектура ADALINE (adaptive linear neuron — адаптивный линейный нейрон) — искусственный нейрон с линейной функцией активации на текущий момент считается устаревшей.

Автор замечательной книги «Python и машинное обучение» Себастьян Рашка (он же начал приведенное выше обсуждение в рассылке разработчиков scikit-learn) приводит свою реализацию алгоритма в бумажной книге, онлайн-версии книги и на гитхабе в образовательных целях и в качестве музейного экспоната.

Но мы с вами провели всё это время с нейроном ADALINE не зря.

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

Во-вторых, если мы отрежем от нашего нейрона порог (квантизатор) так, что на выходе будет не двоичная метка класса, а значение активации на непрерывной шкале, мы вместо нейрона получим алгоритм построения линейной регрессии в чистом виде (значение активации будет значением целевой переменной) — это вполне себе актуальный метод машинного обучения, реализация которого присутствует в том числе в библиотеке scikit-learn.

S. P. Перцептрон Розенблатта по времени появился раньше, архитектурно, судя по описанию, они идентичны, но в статьях про ADALINE почему-то не ссылаются на Розенблатта, а ссылаются на других людей. У меня так сходу не получилось проследить, чем отличается нейрон ADALINE от перцептрона Розенблатта с линейной функцией активации и отличаются ли они вообще. Возможно, кто-то в комментариях сможет этот момент прояснить. Поэтому здесь я осторожно предполагаю, что ADALINE можно считать специальным названием для единичного перцептрона Розенблатта с линейной функцией активации.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть