Обратное распространение ошибки для сверточной нейронной сети

Время на прочтение
5 мин

Количество просмотров 82K

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

Обучение сверточной нейронной сети

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

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

Алгоритм обратного распространения ошибки

Для обучения описанной нейронной сети был использован алгоритм обратного распространения ошибки (backpropagation). Этот метод обучения многослойной нейронной сети называется обобщенным дельта-правилом. Метод был предложен в 1986 г. Румельхартом, Макклеландом и Вильямсом. Это ознаменовало возрождение интереса к нейронным сетям, который стал угасать в начале 70-х годов. Данный алгоритм является первым и основным практически применимым для обучения многослойных нейронных сетей.

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

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

Величина ошибки определяется по формуле 2.8 среднеквадратичная ошибка:

Неактивированное состояние каждого нейрона j для образа p записывается в виде взвешенной суммы по формуле 2.9:

Выход каждого нейрона j является значением активационной функции

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

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

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

Производную функции ошибки по конкретному образу можно записать по правилу цепочки, формула 2.12:

Ошибка нейрона обычно записывается в виде символа δ (дельта). Для выходного слоя ошибка определена в явном виде, если взять производную от формулы 2.8, то получим t минус y, то есть разницу между желаемым и полученным выходом. Но как рассчитать ошибку для скрытых слоев? Для решения этой задачи, как раз и был придуман алгоритм обратного распространения ошибки. Суть его заключается в последовательном вычислении ошибок скрытых слоев с помощью значений ошибки выходного слоя, т.е. значения ошибки распространяются по сети в обратном направлении от выхода к входу.

Ошибка δ для скрытого слоя рассчитывается по формуле 2.13:

Алгоритм распространения ошибки сводится к следующим этапам:

  • прямое распространение сигнала по сети, вычисления состояния нейронов;
  • вычисление значения ошибки δ для выходного слоя;
  • обратное распространение: последовательно от конца к началу для всех скрытых слоев вычисляем δ по формуле 2.13;
  • обновление весов сети на вычисленную ранее δ ошибки.

Алгоритм обратного распространения ошибки в многослойном персептроне продемонстрирован ниже:

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

Расчет ошибки на подвыборочном слое

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


Рисунок 2.8 — Интерпретация операции свертки в многослойный вид, где связи с одинаковым цветом имеют один и тот же вес. Синим цветом обозначена подвыборочная карта, разноцветным – синаптическое ядро, оранжевым – получившаяся свертка

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

Рисунок 2.9 — Вычисление δ подвыборочного слоя за счет δ сверточного слоя и ядра

Обратная свертка – это тот же самый способ вычисления дельт, только немного хитрым способом, заключающийся в повороте ядра на 180 градусов и скользящем процессе сканирования сверточной карты дельт с измененными краевыми эффектами. Простыми словами, нам необходимо взять ядро сверточной карты (следующего за подвыборочным слоем) повернуть его на 180 градусов и сделать обычную свертку по вычисленным ранее дельтам сверточной карты, но так чтобы окно сканирования выходило за пределы карты. Результат операции обратной свертки в соответствии с рисунком 2.10, цикл прохода обратной свертки в соответствии с рисунком 2.11.

Рисунок 2.10 — Результат операции обратной свертки


Рисунок 2.11 — Повернутое ядро на 180 градусов сканирует сверточную карту

Расчет ошибки на сверточном слое

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

Заключение

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

Источники

Алгоритм обратного распространения ошибки для сверточной нейронной сети

Обратное распространение ошибки в сверточных слоях
раз и два

Обратное распространение ошибки в персептроне

Еще можно почитать в РГБ диссертацию Макаренко: АЛГОРИТМЫ И ПРОГРАММНАЯ СИСТЕМА КЛАССИФИКАЦИИ

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

Формат данных

Картинки в большинстве случаев представляют собой упорядоченный набор пикселей, где каждый пиксель – это вектор из трех «каналов» (интенсивность красного, интенсивность зелёного, интенсивность синего).

2_rgb_rooster_1.svg

Каждая интенсивность характеризуется числом от 0 до 1, но для привычных нам изображений этот интервал равномерно дискретизирован, чтобы уместиться в 8 бит (от 0 до 255), для экономии памяти. При этом (0, 0, 0), нулевая интенсивность, соответствует чёрному цвету, а (255, 255, 255), максимальная интенсивность, – белому.

Когда мы наблюдаем изображение на мониторе компьютера, мы видим эти пиксели «уложенными» в строки одинаковой длины (человек не сможет воспринять картинку, вытянутую в один вектор). Длину каждой такой строки называют шириной W картинки, а количество строк – высотой H. Резюмирую, мы можем рассматривать картинку, как тензор HxWx3, состоящий из чисел uint8.

1_rgb_split_rooster_1.svg

Существует множество разных форматов хранения картинок: вместо трех интенсивностей мы можем использовать триплет (оттенок,насыщенность,интенсивность), а сами картинки хранить, например, как тензор CxHxW.

MLP

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

Однако, такой подход имеет несколько недостатков:

  1. Количество параметров. В первом слое у нас получается HxWxCxCout параметров, где Cout – это количество нейронов в первом слое. Если поставить Cout слишком маленьким, мы рискуем потерять много важной информации, особенно, если рассматривать картинки размером, например, 1920×1080. Если же выставить Cout большим, рискуем получить слишком много параметров (а это только первый слой), а с этим и все вытекающие проблемы (переобучение, сложность оптимизации).

  2. Структура данных никак не учитывается. Что здесь имеется в виду под «структурой»? Попробуем объяснить на примере. Для этого рассмотрим картинку щеночка:

    puppy.jpg

    Если мы сдвинем картинку на несколько пикселей, то мы все еще будем уверены в том, что это щенок:

    shifted_puppy.jpg

    Точно также мы останемся неизменны в своем мнении, если картинку отмасштабировать:

    scaled_puppy.jpg

    или повернуть/развернуть:

    rotated_puppy.jpg
    flipped_puppy.jpg

Получается, что нейронная сеть должна «сама» понять, что ее ответ должен быть инвариантен к описанным преобразованиям. Но, обычно, это достигается за счет увеличения количества нейронов в скрытых слоях (как мы можем помнить из universal approximation theorem), что и так для нас является головной болью из-за первого пункта.

С частью этих проблем нам поможет новый «строительный блок» – свёртка. О ней в следующем разделе.

Свёртки

Мотивация

Строгое определение свёртки мы дадим ниже, а вначале разберёмся в мотивации.

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

gif_shift_template.svg

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

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

padded_puppy.jpg

Возьмём окно размером с исходную картинку, и будем его сдвигать на все возможные смещения внутри нового изображения:

gif_conv_template0.svg

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

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

11_image_tensor_conv_cdaf6d395d.svg

Каждый «кубик» на картинке – это число. Большой черный тензор слева – это изображение щеночка $X$. Фиолетовым на нем выделено окно, из которого мы достаем все пиксели и разворачиваем в вектор (аналогично операции flatten в numpy) $v$. Далее этот вектор умножается на вектор весов класса «щенок» $w_1$, и получается число $k_1$ – логит интересующего класса. Добавив остальные классы, получим матрицу весов $W$ – прямо как в мультиномиальной логистической регрессии. Эту операцию мы повторяем для каждого возможного сдвига окна свёртки.

Результаты домножения удобно бывает скомпоновать в двумерную табличку, которую при желании можно трактовать, как некоторую новую картинку (в серых тонах, потому что канал уже только один). Воспользуемся этим, чтобы получше осознать, что происходит в ходе свёртки.

Вопрос на подумать. Какой геометрический смысл имеет свёртка с ядром

$$B_1 = frac19begin{pmatrix}1 & 1 & 1\ 1 & 1 & 1\ 1 & 1 & 1end{pmatrix}?$$

А с ядром

$$B_2 = begin{pmatrix}-1 & -1 & -1\ -1 & 8 & -1\ -1 & -1 & -1end{pmatrix}?$$

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

conv_examples.png

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

Вопрос на подумать. На краях картинок из ответа к предыдущему вопросу заметны тёмные рамки. Что это такое? Откуда они берутся?

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

  • Дополнить изображение по краям нулями. Когда мы будем рассматривать окна свёртки с центрами в крайних пикселях, они будут захватывать эти нули. Такая свёртка будет превращать изображение размером HxWx3 в изображение размером HxW, без уменьшения размера. Но так как нули соответствуют чёрному цвету, это будет вносить определённые изменения в крайние пиксели результата. Именно благодаря этому у картинок из предыдущего вопроса на подумать по краям появились тёмные рамки.
  • Разрешить только такие окна, которые целиком лежат внутри изображения. Это будет приводить к падению размера. Например, для окна размером 5x5 картинка размером HxWx3 превратится в картинку размером (H-2)x(W-2).

Решив проблему обеспечения устойчивости к сдвигу картинки и имея на руках наш огромный свёрточный фильтр, давайте попробуем теперь справиться с первой проблемой – количество параметров. Самое простое, что можно придумать, – это уменьшить размер окна с HxW до, допустим, kxk (обычно нечётное и $k in [3,11])$. В этом случае получается радикальное снижение количества параметров и сложности вычислений.

gif_small_conv_template.svg

К сожалению, с таким подходом возникает новая проблема: предсказание для какого-то окна никак не учитывает контекст вокруг него. Получается, мы можем получить разумные предсказания только в случае, если распознаваемый объект обладает признаками, которые «помещаются» в окно свёртки (например, лого автомобиля при классификации марок машин), либо объекты заметно отличаются по своей текстуре (шерсть кошки vs кирпич, например). На картинке ниже сделана попытка изобразить проблему:

very_small_conv.png

Область картинки, на которую «смотрит» наша нейронная сеть, назвается receptive field – и про него приходится часто думать в задачах компьютерного зрения. Давайте и мы подумаем, как его можно было бы увеличить, не увеличивая размер ядра. Вспомним, что в нашей нейронке сейчас есть только один слой, сразу предсказывающий класс. Выглядит так, что мы можем применить уже знакомую технику стекинга слоев: пусть на первой стадии мы делаем $C_1$ разных свёрток с фильтрам размером kxk. Результаты каждой свёртки можно упорядочить в виде новой «картинки», а из этих «картинок» сложить трёхмерный тензор. Получаем так называемую карту признаков размером HxWxC_1. Применим к ней поэлементно нелинейность и воспользуемся K новыми свёртками для получения предсказаний для каждого пикселя. На таком шаге получается, что наш receptive field для финальных нейронов вырос от kxk до (2k-1)x(2k-1) (пояснение на картинке). Повторяя такую операцию, мы можем добиться, чтобы наши финальные нейроны уже могли «видеть» почти всю нужную информацию для хорошего предикта. Более того, у нас возникает меньшее количество параметров и падает сложность вычислений в сравнении с использованием одной большой полносвязной сети.

Как это схематично выглядит:

15_conv_composition_e686591745.svg

Промежуточный тензор $L_1$, полученный при помощи $C_1$ свёрток, можно себе представить, как новую картинку, у которой уже $C_1$ каналов.

На следующей картинке можно отследить, как меняется receptive field в зависимости от глубины:

16_receptive_field_fbd1c12519.svg

На картинке схематично изображен «плоский» двумерный тензор (количество каналов = 1), к которому последовательно применили три свёртки 3×3. В каждом случае рассматривается пиксель в центре. Каждый соответствующий тензор помечен, как $L_i$. Если рассматривать первую свёртку ($Xto L_1$), то размер receptive field равен размеру е окна = 3. Рассмотрим вторую свёртку $L_1 to L_2$. В ее вычислении участвуют пиксели из квадрата 3х3, причём каждый из них, в свою очередь, был получен при помощи предыдущей свёртки $X to L_1$. Получается, что receptive field композиции свёрток $Xto L_1to L_2$ – это объединение receptive fields свёртки $Xto L_1$ по всем пикселям из окна свёртки $L_1to L_2$, образуя новый, размером 5×5. Аналогичные рассуждения можно повторить и для всех последующих свёрток.

Ещё один способ увеличить receptive field – это использовать dilated convolution, в которых окно свёртки (то есть те пиксели картинки, на которые умножается ядро) не обязано быть цельным, а может идти с некоторым шагом (вообще говоря, даже разным по осям H и W). Проиллюстрируем, как будет выглядеть окно для обычной свёртки и для свёртки с шагом dilation=2:

dilated_convolution.svg

Если установить параметр dilation=(1,1), получится обычная свёртка.

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

Формальное определение свёртки

18_conv_def_142fec410a.svg

Вопрос на подумать. Пусть у нас есть тензор размером HxWxC_{in}, к которому одновременно применяется $C_{out}$ свёрток, размер окна каждой равен kxk. Посчитайте количество обучаемых параметров. Как изменится формула, если к свёртке добавить смещение (bias)? Во сколько раз изменится количество параметров, если увеличить размер окна в 2 раза? А если увеличить количество каналов $C_{in}$ и $C_{out}$ в два раза? А если увеличить размер входного тензора в 2 раза по высоте и ширине?

Вопрос на подумать. Оцените количество операций сложений-умножений для предыдущего упражнения. Как оно поменяется, если увеличить в два раза размер окна? Количество каналов? Размер входного тензора?

Вопрос на подумать. Пусть последовательно применяется $N$ свёрток $k times k$. Посчитайте размер receptive field для последнего оператора.

Свёртки не только для изображений

Нетрудно видеть, что аналоги двумерной свёртки можно определить и для тензоров другой размерности, в любой ситуации, когда для нас актуально поддерживать устойчивость модели к сдвигам данных. Например, это актуально для работы с текстами. Обычно текст разбивается на последовательные токены (например, на слова или какие-то subword units), и каждому из этих токенов ставится в соответствие вектор (более подробно об этом вы можете почитать в главе про работу с текстами или в разделе про вложения слов учебника по NLP Лены Войта).

cnn word 1.svg

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

cnn_word.svg

Существуют свёртки и для тензоров более высокой размерности, например, для видео (где прибавляется ещё координата «время»).

Инвариантность не только к сдвигам?

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

В качестве потенциально интересного – но пока не проявившего себя на практике – направления исследований можно упомянуть капсульные нейросети. Кроме того, вам может быть интересно познакомиться с геометрическим глубинным обучением(в качестве короткого введения рекомендуем посмотреть вот этот keynote с ICLR 2021), которое ставит своей целью исследование общих принципов, связывающих устойчивость к различным преобразованием и современные нейросетевые архитектуры (авторы сравнивают свои идеи с эрлангенской программой Феликса Кляйна – отсюда название).

Свёрточный слой и обратное распространение ошибки

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

Начнём с иллюстрации для одномерной свёртки с одним входным каналом, ядром длины $3$ с дополнением по бокам нулями. Заметим, что её можно представить в виде матричного умножения:

$$(x_1,ldots,x_d) ast (w_{-1},w_0,w_1) = $$

$$= (0,x_1,ldots,x_d,0) cdotbegin{pmatrix}
w_{-1} & & & & \
w_0 & w_{-1} & & & \
w_1 & w_0 & w_{-1} & & \
& w_1 & w_0 & ddots & \
& & w_1 & ddots & w_{-1} \
& & & ddots & w_0 \
& & & & w_1 \
end{pmatrix} = $$

$$= (x_1,ldots,x_d) cdotbegin{pmatrix}
w_0 & w_{-1} & & & & & \
w_1 & w_0 & w_{-1} & & & & \
& w_1 & w_0 & w_{-1} & && \
& & w_1 & w_0 & ddots & & \
& & & w_1 & ddots & w_{-1} & \
& & & & ddots & w_0 & w_{-1} \
& & & & & w_1 & w_0 \
end{pmatrix} = $$

Обозначим последнюю матрицу через $widehat{W}$, а ядро свёртки через $W$. Что происходит с градиентом при переходе через матричное умножение, мы уже отлично знаем. Градиент по весам равен

$$nabla_{X_0}mathcal{L} = nabla_{X_0ast W}mathcal{L}cdotwidehat{W}^T$$

Разберёмся, что из себя представляет умножение на $widehat{W}^T$ справа. Эта матрица имеет вид

$$begin{pmatrix}
w_0 & w_1 & & & & & \
w_{-1} & w_0 & w_1 & & & & \
& w_{-1} & w_0 & w_1 & && \
& & w_{-1} & w_0 & ddots & & \
& & & w_{-1} & ddots & w_1 & \
& & & & ddots & w_0 & w_1 \
& & & & & w_{-1} & w_0 \
end{pmatrix}$$

Она тоже соответствует свёртке, только:

  • с симметричным исходному ядром $(w_1, w_0, w_{-1})$;
  • с дополнением вектора $nabla_{X_0ast W}$ нулями (это как раз соответствует неполным столбцам: можно считать, что «выходящие» за границы матрицы и отсутствующие в ней элементы умножаются на нули).

Вопрос на подумать. Поменяется ли что-нибудь, если исходный вектор не дополнять нулями?

Общий случай

Рассмотрим теперь двумерную свёртку, для простоты нечётного размера и без свободного члена

$$(Xast W)_{ijc} = sum_{p=1}^{c_{text{in}}}sum_{k_1 = -k}^ksum_{k_2=-k}^kW^{c}_{k+1+k_1, k+1+k_2, p}X_{i + k_1, j + k_1, p}$$

  1. Продифференцируем по $X_{stl}$:

$$frac{partialmathcal{L}}{partial X_{stl}} = sum_{i, j, c}frac{partial (Xast W)_{ijc}}{partial X_{stl}}cdotfrac{partialmathcal{L}}{partial(Xast W)_{ijc}}$$

Разберёмся с производной $frac{partial (Xast W)_{ijc}}{partial X_{stl}}$. Во всей большой сумме из определения свёртки для $(Xast W)_{ijc}$ элемент $X_{stl}$ может встретиться в позициях $X_{i+k_1, j+k_2, l}$ при $i + k_1 = s$, $j + k_2 = t$ и всевозможных $c$, причём это возможно лишь если $k_1 = s — iin{-k,ldots,k}$, $k_2 = t — jin{-k,ldots,k}$ (для всех остальных $(Xast W)_{ijc}$ производная по $X_{stl}$ нулевая). Соответствующий коэффициент при $X_{stl}$ будет равен $W_{k + 1 + k_1, k + 1 + k_2, c}$. Таким образом, производная будет иметь вид:

$$frac{partialmathcal{L}}{partial X_{stl}} = sum_{c=1}^{c_{text{out}}}sum_{k_1=-k}^ksum_{k_2=-k}^kW_{k + 1 + k_1, k + 1 + k_2, c}cdotfrac{partialmathcal{L}}{partial(Xast W)_{s — k_1, t — k_2, c}}$$

Легко заметить, что это тоже свёртка, но поскольку индексы $k_1, k_2$ в $W$ и в $frac{partialmathcal{L}}{partial(Xast W)}$ стоят с разными знаками, получаем, что

$$color{blue}{nabla_{X}mathcal{L} = Wtext{[::-1,::-1,:]}astnabla_{Xast W}mathcal{L}}$$

  1. Продифференцируем по $W^q_{ab}$:

$$frac{partialmathcal{L}}{partial W^q_{ab}} = sum_{i, j, c}frac{partial (Xast W)_{ijc}}{partial W^q_{ab}}cdotfrac{partialmathcal{L}}{partial(Xast W)_{ijc}}$$

В формуле для $(Xast W)_{ijc}$ элемент $W^q_{ab}$ может встретиться в позициях $W^q_{k + 1 + k_1, k + 1 + k_2}$, для $k + 1 + k_1 = a$, $k + 1 + k_2 = b$, с коэффициентами $X_{i + k_1, j + k_2, p}$ (для любых $p$). Значит, производная будет иметь вид:

$$frac{partialmathcal{L}}{partial W^q_{ab}} = sum_{p=1}^{c_{text{in}}}sum_{i=1}^Hsum_{j=1}^WX_{a — k — 1, b — k — 1, p}cdotfrac{partialmathcal{L}}{partial(Xast W)_{a — k — 1, b — k — 1, q}}$$

В этой формуле тоже нетрудно узнать свёртку:

$$color{blue}{nabla_{W}mathcal{L} = Xastnabla_{Xast W}mathcal{L}}$$

Вопрос на подумать. Если всё-таки есть свободные члены, как будет выглядить градиент по $b_c$?

Остальные важные блоки свёрточных нейронных сетей

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

Max pool

Каждая из $C$ свёрток очередного свёрточного слоя – это новая карта признаков для нашего изображения, и нам, конечно, хотелось бы, чтобы таких карт было побольше: ведь это позволит нам выучивать больше новых закономерностей. Но для картинок в высоком разрешении это может быть затруднительно: слишком уж много будет параметров. Выходом оказалось использование следующей эвристики: сначала сделаем несколько свёрток с $C_1$ каналами, а затем как-нибудь уменьшим нашу карту признаков в 2 раза и одновременно увеличим количество свёрток во столько же. Посчитаем, как в таком случае изменится число параметров: было $H times W times K times K times C_1 times C_1$, стало $(H/2) times (W/2) times K times K times (C_1 times 2) times (C_1 times 2) = H times W times K times K times C_1 times C_1$, то есть, ничего не изменилось, а количество фильтров удвоилось, что приводит к выучиванию более сложных зависимостей.

Осталось разобраться, как именно можно понижать разрешение картинки. Тривиальный способ – взять все пиксели с нечетными индексами. Такой подход будет работать, но, как может подсказать здравый смысл, выкидывать пиксели = терять информацию, а этого не хотелось бы делать. Здесь есть много вариантов: например, брать среднее/максимум по обучаемым весам в окне 2x2, которое идет по карте признаков с шагом 2. Экспериментально выяснилось, что максимум – хороший выбор, и, в большинстве архитектур, используют именно его. Обратите внимание, что максимум берется для каждого канала независимо.

Еще одно преимущество – увеличение receptive field. Получается, что он увеличивается в 2 раза:

21_maxpool_b19a0a26b2.svg

Операция понижения разрешения со взятием максимума в окне называется max pooling, а со взятием среднего – average pooling.

Вопрос на подумать. Как будет преобразовываться градиент во время error backpropagation для maxpool с окном и шагом 2×2? А для average pool?

Кстати, ещё одним способом уменьшать размер карт признаков по ходу применения свёрточной сети является использование strided convolution, в которых ядро свёртки сдвигается на каждом шаге на некоторое большее единицы число пикселей (возможно, разное для осей H и W; обычная свёртка получается, если установить параметр stride=(1,1)).

gif_conv_template1.svg

Global average pool

Как свёрточные слои, так и пулинг превращают картинку в «стопку» карт признаков. Но если мы решаем задачу классификации или регрессии, то в итоге нам надо получить число (или вектор логитов, если речь про многоклассовую классификацию). Один из способов добиться этого – воспользоваться тем, что свёртка без дополнения нулями и пулинг уменьшают размер карты признаков, и в итоге при должном терпении и верном расчёте мы можем получить тензор 1x1xC (финальные, общие признаки изображения), к которому уже можно применить один или несколько полносвязных слоёв. Или же можно, не дождавшись, пока пространственные измерения схлопнутся, «растянуть» всё в один вектор и после этого применить полносвязные слои (именно так, как мы не хотели делать, не правда ли?). Примерно так и происходило в старых архитектурах (alexnet, vgg).

Вопрос на подумать. Попробуйте соорудить конструкцию из свёточных слоёв и слоёв пулинга, превращающую изображение размера 128x128x3 в тензор размера 1x1xC.

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

Позднее было предложено следующее: после скольких-то свёрточных слоёв мы будем брать среднее вдоль пространственных осей нашего последнего тензора и усреднять их активации, а уже после этого строить MLP. Это и есть Global Average Pooling. У такого подхода есть несколько преимуществ:

  1. Радикально меньше параметров
  2. Теперь мы можем применять нейронку к картинку любого размера
  3. Мы сохраняем «магию» инвариантности предсказаний к сдвигам

23_globalpool_8b3ea2ca37.svg

Residual connection

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

24_residual_bb9168282a.svg

Таким образом получается, что градиент доплывет даже до самых первых слоев, что существенно ускоряет сходимость и качество полученной модели. Вопрос: почему именно сумма? Может, лучше конкатенировать? Авторы densenet именно такой подход и предлагают (с оговорками), получая результаты лучше, чем у resnet. Однако, такой подход получается вычислительно сложным и редко используется на практике.

Регуляризация

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

Классические

Почти все регуляризаторы, которые использовались в классической машинке и полносвязных сетях, применимы и здесь: l1/l2, dropout и так далее.

Вопрос на подумать. Насколько разумно использовать dropout в свёрточных слоях? Как можно модифицировать метод, чтобы он стал «более подходящим»?

Аугментации

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

  1. сдвиги, повороты и отражения;
  2. добавление случайного гауссового шума;
  3. вырезание случайно части картинки (cutout);
  4. перспективные преобразования;
  5. случайное изменение оттенканасыщщеностияркости для всей картинки;
  6. и многое другое.

Пример хорошой библиотеки с аугментациями: Albumentations.

Label smoothing

Часто оказывается, что нейронная сеть делает «слишком уверенные предсказания»: 0.9999 или 0.00001. Это становится головной болью, если в нашей разметке есть шум – тогда градиенты на таких объектах могут сильно портить сходимость. Исследователи пришли к интересной идее: давайте предсказывать не one-hot метку, а ее сглаженный вариант. Итак, пусть у нас есть $K$ классов:

$$y_{ohot}=(0, 0, dots, 1, dots, 0)$$

$$y_{ls}=left(frac{varepsilon}{k-1},frac{varepsilon}{k-1}, dots, 1-varepsilon,frac{varepsilon}{k-1}, dots, frac{varepsilon}{k-1}right)$$

$$sum_i y^i_{ohot}=sum_i y^i_{ls}=1$$

Обычно берут $varepsilon=0.1$. Тем самым модель штрафуется за слишком уверенные предсказания, а шумные лейблы уже не вносят такого большого вклада в градиент.

Mixup

Самый интересный вариант. А что будет, если мы сделаем выпуклую комбинацию двух картинок и их лейблов:

мл 6_1.svg

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

Итого

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

Знаковые архитектуры в мире свёрточных нейронных сетей для задачи классификации изображений

Дисклеймер: это мнение одного автора. Приведённые в этом разделе вехи связаны преимущественно с архитектурами моделей, а не способом их оптимизации.

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

Зачем это полезно изучить (вместе с чтением статей)? Основных причин две:

  1. Общее развитие. Полезно понимать, откуда взялись и чем мотивированы те или иные компоненты.
  2. Этот вопрос задают на собеседовании, когда не знают, что еще спросить :)

lenet (1998)

Ссылка на статью

7 слоев

Первая свёрточная нейронная сеть, показавшая SOTA (State Of The Art) результаты на задаче классификации изображений цифр MNIST. В архитектуре впервые успешно использовались свёрточные слои с ядром 5x5. В качестве активации использовался tanh, а вместо max pool в тот момент использовался average.

alexnet (2012)

Ссылка на статью

11 слоев

Первая CNN (Convolutional Neural Network), взявшая победу на конкурсе imagenet. Автор предложил использовать ReLU вместо сигмоид (чтобы градиенты не затухали) и популяризовал max-pool вместо average. Что самое важное, обучение модели было перенесено на несколько GPU, что позволило обучать достаточно большую модель за относительное небольшое время (6 дней на двух видеокартах того времени). Также автор обратил внимание, что глубина нейросети важна, так как убирание хотя бы одного слоя стабильно ухудшало качество на несколько процентов.

network in network (2013)

Ссылка на статью

В статье не привели интересных SOTA результатов, но зато ввели два очень популярных впоследствии модуля. Первый – это GAP (Global Average Pooling), который стоит после последнего свёрточного слоя и усредняет все активации вдоль пространственных осей. Второй – стекинг 1x1 свёрток поверх 3x3, что эквивалентно тому, что вместо линейной свёртки используется полносвязный слой.

vgg (2014)

Ссылка на статью

19 слоев

Авторы предложили декомпозировать большие свёртки (5x5, 7x7 и выше) на последовательное выполнение свёрток 3x3 с нелинейностями между ними. Впоследствии, за нечастым исключением, свёртки 3x3 стали стандартом в индустрии (вместе со свёртками 1x1).

googleLeNet aka Inception (2014)

Ссылка на статью

22 слоя

Ввели inception слой, просуществовавший довольно продолжительное время. Сейчас сам слой уже не используется, но идея лежащая в его основе, эксплуатируется. Идея следующая: будем параллельно применять свёртки с разным пространственными размерами ядер, чтобы можно было одновременно обрабатывать как low, так и high level признаки. Еще полезной для сообщества оказалась идея с dimensionality reduction: перед тяжелой операцией поставим свёртку 1×1, чтобы уменьшить количество каналов и кратно ускорить вычисление.

batch normalization (2015)

Ссылка на статью

Авторы внедрили вездесущую batch normalization, которая стабилизирует сходимость, позволяя увеличить шаг оптимизатора и скорость сходимости. Применив идею к архитектуре inception, они превзошли человека на imagenet.

kaiming weight initialization (2015)

Ссылка на статью

В статье предложили использовать инициализацию весов, берущую во внимание особенность активации ReLU (в предыдущих работах предполагалось, что $Var[x] = mathbb{E}[x^2]$, что, очевидно, нарушается для $hat{x} = max(0, x)$). Применение этой и других «свистелок:: на VGG19 позволило существенно уменьшить ошибку на imagenet.

ResNet (2015)

Ссылка на статью

152 слоя

Архитектура, до сих пор (на момент написания – вторая половина 2021 года) являющаяся бейзлайном и отправной точкой во многих задачах. Основная идея – использование skip connections, что позволило градиенту протекать вплоть до первых слоев. Благодаря этому эффекту получилось успешно обучать очень глубокие нейронные сети, например, с 1202 слоями (впрочем, результаты на таких моделях менее впечатляющие, чем на 152 слойной). После этой статьи также стали повсеместно использоваться GAP и уменьшение размерности свёртками 1x1.

MobileNet (2017)

Ссылка на статью

Очень популярная модель для быстрого инференса (на мобильных устройствах или gpu). По качеству хоть и немного проигрывает «монстрам», но в индустрии, оказывается, зачастую этого достаточно (особенно если брать последние варианты модели). Основная деталь – это использование depthwise convolutions: параллельный стекинг свёрток 3x3x1x1 – то есть таких, в которых вычисление для каждого $с_{text{out}}$ канала просходит только на основе признаков одного $c_{text{in}}$ канала. Чтобы скомбинировать фичи между каналами, используется классическая 1x1 свёртка.

EfficientNet (2019)

Ссылка на статью

Одна из первых моделей, полученных при помощи NAS (Neural Architecture Search), которая взяла SOTA на imagenet. После этого, модели, где компоненты подбирались вручную, уже почти не показывали лучших результатов на классических задачах.

Бонус: не классификацией единой

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

  1. Сегментация. Если убрать в конце слои GlobalAveragePool или flatten, то можно делать предсказания для каждого пикселя в отдельности (подумайте, что делать, если в сети есть maxpool) – получаем сегментацию картинки. Проблема – долгая и дорогая разметка.
  2. Детекция. Часто намного дешевле получить разметку объектов обрамляющими прямоугольниками. Здесь уже можно для каждого пикселя предсказывать размеры прямоугольника, который обрамляет объект, к которому принадлежит пиксель. Проблемы – нужен этап агрегации прямоугольников + много неоднозначностей во время разметки + много эверистик на всех этапах + данных нужно больше.
  3. Понимание видео. Добавляем в тензор новый канал – временной, считаем четырехмерные свёртки – и получаем распознавание сцен на видео.
  4. Metric learning. Часто мы не можем собрать все интересующие нас классы, например, в задаче идентификации человека по лицу (или товара на полке). В этом случае используют такой трюк: научим модель в некотором смысле (обычно по косиносному расстоянию) разделять эмбеддинги существующих классов (уникальных людей). Если на руках была репрезентативная выборка, то модель, скорее всего (а обычно – всегда), выучит генерировать дискриминативные эмбеддинги, которые уже позволят различать между собой ранее невиданные лица.
  5. и многое другое

Обратное распространение в сверточном слое


  Перевод


  Ссылка на автора

Обратное распространение в сверточном слое

Введение

мотивация

Цель этого поста — подробно описать, как работает обратное распространение градиента в сверточном слое нейронной сети. Обычно выход этого слоя будет входом выбранной функции активации (reluнапример). Мы делаем предположение, что нам дан градиентdyобратно от этой функции активации. Поскольку я не смог найти в Интернете полное, подробное и «простое» объяснение того, как это работает. Я решил сделать математику, пытаясь понять шаг за шагом, как она работает на простых примерах, прежде чем обобщать. Перед дальнейшим чтением вы должны быть знакомы с нейронными сетями, и особенно с прямым проходом, обратным распространением градиента в вычислительном графе и базовой линейной алгеброй с тензорами.

Сверточный слой — прямой проход и BP

нотации

*будет относиться к свертке 2 тензоров в случае нейронной сети (входxи фильтрw).

  • когдаxа такжеwэто матрицы:
  • еслиxа такжеwиметь одинаковую форму,x*wбудет скаляр, равный сумме по результатам поэлементного умножения между массивами.
  • еслиwменьшеx, мы получим карту активацииyгде каждое значение является предопределенной операцией свертки подобласти x с размерами w. Этот субрегион, активируемый фильтром, скользит по всему входному массивуx,
  • еслиxа такжеwимеют более двух измерений, мы рассматриваем последние 3 для свертки и последние 2 для выделенной области скольжения (мы просто добавляем одну глубину к нашей матрице)

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

Обозначения сверточного слоя

Цель

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

Вход состоит из N точек данных, каждая из которых имеет каналы C, высоту H и ширину W. Мы сворачиваем каждый вход с F различными фильтрами, где каждый фильтр охватывает все каналы C и имеет высоту HH и ширину WW.

Входные данные:

  • x: входные данные формы (N, C, H, W)
  • w: фильтр весов формы (F, C, HH, WW)
  • b: уклоны формы (F,)
  • conv_param: словарь со следующими ключами:
  • ‘Шага’: количество пикселей между смежными рецептивными полями в горизонтальном и вертикальном направлениях.
  • ‘Pad’: количество пикселей, которые будут использоваться для ввода нуля ввода.

Во время заполнения нули «pad» должны располагаться симметрично (т.е. одинаково с обеих сторон) вдоль осей высоты и ширины входа.

Возвращает кортеж из:

  • out: Выходные данные формы (N, F, H ’, W’), где H ’и W’, определяются как

H ’= 1 + (H + 2 * pad — HH) / шаг

W ’= 1 + (W + 2 * pad — WW) / шаг

  • кеш: (x, w, b, conv_param)

Прямой проход

Общий случай (упрощенно с N = 1, C = 1, F = 1)

N = 1 один вход, C = 1 один канал, F = 1 один фильтр.

Свертка 2D

х: В × Ш
х = х с дополнением
w: HH × WW
смещение b: скаляр
y: H ′ × W ′
шаг с

Особый случай: шаг = 1, пад = 0 и без смещения.

обратное распространение

Мы знаем:

Мы хотим вычислитьдх,с.в.а такжедецибел, частные производные нашего стоимостного функционала L. Предположим, что градиент этой функции был обратно распространен до y.

Тривиальный случай: вход x является вектором (1 измерение)

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

вход

Выход

Прямой проход — свертка с одним фильтром w, шагом = 1, заполнением = 0

обратное распространение

Мы знаем градиент нашей функции стоимости L относительно y:

Это можно записать с помощью якобианской нотации:

DY и Y имеют одинаковую форму:

Мы ищем

децибел

Используя правило цепочки и формулу прямого прохода (1), мы можем написать:

с.в.

Мы можем заметить, что dw является сверткой входа x с фильтром dy. Давайте посмотрим, действительно ли это все еще действует с дополнительным измерением.

дх

Еще раз, у нас есть свертка. На этот раз немного сложнее. Мы должны рассмотреть вход dy с 0-отступом размера 1, свернутый с «инвертированным» фильтром w, как (вес2,вес1)

Следующим шагом будет посмотреть, как это работает на маленьких матрицах.

Ввод x представляет собой матрицу (2 измерения)

вход

Выход

Еще раз, мы выберем самый простой случай: шаг = 1 и без заполнения Форма у будет (3,3)

Перевал

Мы будем иметь:

Написано с подписками:

обратное распространение

Мы знаем:

децибел

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

Суммирование по i и j. И у нас есть:

с.в.

Мы ищем

Используя формулу (4) имеем:

Все условия

Кроме (К,Lзнак равном,N), где это 1, случай, встречающийся только один раз в двойной сумме. Следовательно:

Используя формулу (3), мы теперь имеем:

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

дх

Используя правило цепочки, как мы это делали для (5), мы имеем:

На этот раз мы ищем

Используя уравнение (4):

Теперь у нас есть:

В нашем примере наборы диапазонов для индексов:

Когда мы установимКзнак равномя+1 мы выйдем за установленные границы :( мя+ 1) ∈ [-1,4]

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

Еще раз в двойной сумме, у нас только одна частная производная от х равна 1. Итак:

гдевесэто наш 0-расширенный начальный фильтр, таким образом:

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

Используя ∗ обозначение для свертки, имеем:

КакдуОстанемся прежними, мы будем только смотреть на значения индексов w. Длядх22, диапазон для w: 3-я, 3-J

Теперь у нас есть свертка между матрицей dy и w, определяемая как:

Еще один случай, чтобы увидеть, что происходит.дх43, ш: 4-я, 3-J

Последнийдх44

Мы видим всплывающий «инвертированный фильтр» w ’. На этот раз у нас есть свертка между входомдус 0-отступной границей размера 1 и фильтром w, скользящим с шагом 1.

Краткое изложение обратных уравнений

Принимая во внимание глубины

Все становится немного сложнее, когда мы пытаемся принять во внимание глубину (C каналов для входа x, и F различных фильтров для w)

Входы:

  • х: форма (C, H, W)
  • w: форма весов фильтра (F, C, HH, WW)
  • б: форма (F,)

Выходы:

  • y: форма (F, H ’, W’)

Математические формулы видят появление многих индексов, что затрудняет их чтение. Формула прямого прохода в нашем примере будет:

децибел

Расчет дб остается простым, так как каждыйb_fсвязано с картой активацииy_f:

с.в.

Используя формулу прямого прохода, так как двойная сумма не использует индексы dy, мы можем написать:

Алгоритм

Теперь, когда у нас есть интуиция того, как это работает, мы решили не писать весь набор уравнений (что может быть довольно утомительно), но мы будем использовать то, что было закодировано для прямого прохода, и, играя с измерениями, попытаемся закодировать Backprop для каждого градиента. К счастью, мы можем вычислить числовое значение градиента, чтобы проверить нашу реализацию. Эта реализация действительна только для шага = 1, вещь становится немного более сложной с отчетливым шагом, и необходим другой подход. Может быть, для другого поста!

Градиентная числовая проверка

Testing conv_backward_naive function
dx error: 7.489787768926947e-09
dw error: 1.381022780971562e-10
db error: 1.1299800330640326e-10

Почти 0 каждый раз, кажется, все в порядке! :)

Ссылки

  • Этот пост в моем блоге с уравнениями mathjax:)
  • Стэнфордский курс по сверточным нейронным сетям для визуального распознавания
  • Стэнфордское задание CNN 2
  • Сверточная нейронная сеть, прямой проход
  • Слой свертки: Наивная реализация прямого прохода,
  • Обратное распространение в сверточных нейронных сетях
  • Cet Article En Français

Комментарии могут улучшить этот пост, не стесняйтесь связаться со мной!

Превью к статье о свёрточной нейронной сети

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

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

Этап 0. Подготовительный

Прежде чем переходить к созданию свёрточного слоя, необходимо создать структуру для тензора. Тензор, как было сказано в прошлой статье, представляет из себя 3D массив. У тензора имеются три размерности, а значит нам потребуется структура для хранения размера тензора, мы назовём её TensorSize:

// размерность тензора
struct TensorSize {
    int depth; // глубина
    int height; // высота
    int width; // ширина
};

Создав размерность, можно создавать и сам тензор. В нём нам потребуется хранить depth·height·width вещественных чисел, которые мы будем хранить в одномерном векторе для уменьшения количества обращений к элементам по индексам. Для того, чтобы можно было работать с тензором, получать и изменять числа, необходима индексация. Индексация будет производиться по трём индексам в следующем порядке: глубина, высота, ширина. Поскольку в качестве хранения мы выбрали одномерный вектор, то потребуется применить несколько умножений и сложений.

// тензор
class Tensor {
    TensorSize size; // размерность тензора
    std::vector<double> values; // значения тензора

    int dw; // произведение глубины на ширину для индексации

    void Init(int width, int height, int depth);

public:
    Tensor(int width, int height, int depth); // создание из размеров
    Tensor(const TensorSize &size); // создание из размера

    double& operator()(int d, int i, int j); // индексация
    double operator()(int d, int i, int j) const; // индексация

    TensorSize GetSize() const; // получение размера

    friend std::ostream& operator<<(std::ostream& os, const Tensor &tensor); // вывод тензора
};

// инициализация по размерам
void Tensor::Init(int width, int height, int depth) {
    size.width = width; // запоминаем ширину
    size.height = height; // запоминаем высоту
    size.depth = depth; // запоминаем глубину

    dw = depth * width; // запоминаем произведение глубины на ширину для индексации

    values = std::vector<double>(width * height * depth, 0); // создаём вектор из width * height * depth нулей
}

// создание из размеров
Tensor::Tensor(int width, int height, int depth) {
    Init(width, height, depth);  
}

// создание из размера
Tensor::Tensor(const TensorSize &size) {
    Init(size.width, size.height, size.depth);
}

// индексация
double& Tensor::operator()(int d, int i, int j) {
    return values[i * dw + j * size.depth + d];
}

// индексация
double Tensor::operator()(int d, int i, int j) const {
    return values[i * dw + j * size.depth + d];
}

// получение размера
TensorSize Tensor::GetSize() const {
    return size;
}

// вывод тензора
std::ostream& operator<<(std::ostream& os, const Tensor &tensor) {
    for (int d = 0; d < tensor.size.depth; d++) {
        for (int i = 0; i < tensor.size.height; i++) {
            for (int j = 0; j < tensor.size.width; j++)
                os << tensor.values[i * tensor.dw + j * tensor.size.depth + d] << " ";
            
            os << std::endl;
        }

        os << std::endl;
    }

    return os;
}

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

Этап 1. Свёрточный слой

Из прошлой статьи мы узнали, что свёрточный слой содержит в себе набор фильтров и смещений. Также для создания слоя необохдимо задать несколько гиперпараметров, а именно: fs — размер фильтров, fc — количество фильтров, S — шаг свёртки и P — дополнение нулями. Поскольку фильтры являются тензорами, а смещения всего лишь числами, то добавим фильтры в виде вектора тензоров и назовём W, а смещения добавим в виде векторов вещественных чисел и назовём их b. Поскольку эти веса являются обучаемыми, нам потребуется аналогичные наборы векторов для хранения градиентов.

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

class ConvLayer {
    std::default_random_engine generator; // генератор случайных чисел
    std::normal_distribution<double> distribution; // с нормальным распределением

    TensorSize inputSize; // размер входа
    TensorSize outputSize; // размер выхода

    std::vector<Tensor> W; // фильтры
    std::vector<double> b; // смещения

    std::vector<Tensor> dW; // градиенты фильтров
    std::vector<double> db; // градиенты смещений
    
    int P; // дополнение нулями
    int S; // шаг свёртки

    int fc; // количество фильтров
    int fs; // размер фильтров
    int fd; // глубина фильтров

    void InitWeights(); // инициализация весовых коэффициентов

public:
    ConvLayer(TensorSize size, int fc, int fs, int P, int S); // создание слоя

    Tensor Forward(const Tensor &X); // прямое распространение сигнала
    Tensor Backward(const Tensor &dout, const Tensor &X); // обратное распространение ошибки
    void UpdateWeights(double learningRate); // обновление весовых коэффициентов
};

Само создание слоя довольно простое: необходимо запомнить переданные параметры в полях класса и инициализировать весовые коэффициенты фильтров и смещений:

// создание свёрточного слоя
ConvLayer::ConvLayer(TensorSize size, int fc, int fs, int P, int S) : distribution(0.0, sqrt(2.0 / (fs*fs*size.depth))) {
    // запоминаем входной размер
    inputSize.width = size.width;
    inputSize.height = size.height;
    inputSize.depth = size.depth;

    // вычисляем выходной размер
    outputSize.width = (size.width - fs + 2 * P) / S + 1;
    outputSize.height = (size.height - fs + 2 * P) / S + 1;
    outputSize.depth = fc;

    this->P = P; // сохраняем дополнение нулями
    this->S = S; // сохраняем шаг свёртки

    this->fc = fc; // сохраняем число фильтров
    this->fs = fs; // сохраняем размер фильтров
    this->fd = size.depth; // сохраняем глубину фильтров

    // добавляем fc тензоров для весов фильтров и их градиентов
    W = std::vector<Tensor>(fc, Tensor(fs, fs, fd));
    dW = std::vector<Tensor>(fc, Tensor(fs, fs, fd));
        
    // добавляем fc нулей для весов смещения и их градиентов
    b = std::vector<double>(fc, 0);
    db = std::vector<double>(fc, 0);

    InitWeights(); // инициализируем весовые коэффициенты
}

Для заполнения весовых коэффициентов случайными числами просто проходимся по всем тензорам и присваиваем каждому элементу очередное случайное число:

// инициализация весовых коэффициентов
void ConvLayer::InitWeights() {
    // проходимся по каждому из фильтров
    for (int index = 0; index < fc; index++) {
        for (int i = 0; i < fs; i++)
            for (int j = 0; j < fs; j++)
                for (int k = 0; k < fd; k++)
                    W[index](k, i, j) = distribution(generator); // генерируем случайное число и записываем его в элемент фильтра

        b[index] = 0.01; // все смещения устанавливаем в 0.01
    }
}

Этап 2. Прямое распространение сигнала

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

for index = 0 to fc - 1 do
    output[index] = Convolve(X, W[index]) + b[index]

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

Дополнение нулями, padding

Самым простым решением дополнения нулями было бы создание тензора с высотой и шириной на 2P больше и последующим копированием во внутреннюю часть исходного тензора. Увы, подобные копирования сильно замедлят программу, так как требуют постоянных копирований памяти. Мы, конечно, хотим написать простую свёрточную сеть, однако всё-таки постараемся описать её наиболее эффективным способом.

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

// прямое распространение
Tensor ConvLayer::Forward(const Tensor &X) {
    Tensor output(outputSize); // создаём выходной тензор

    // проходимся по каждому из фильтров
    for (int f = 0; f < fc; f++) {
        for (int y = 0; y < outputSize.height; y++) {
            for (int x = 0; x < outputSize.width; x++) {
                double sum = b[f]; // сразу прибавляем смещение

                // проходимся по фильтрам
                for (int i = 0; i < fs; i++) {
                    for (int j = 0; j < fs; j++) {
                        int i0 = S * y + i - P;
                        int j0 = S * x + j - P;

                        // поскольку вне границ входного тензора элементы нулевые, то просто игнорируем их
                        if (i0 < 0 || i0 >= inputSize.height || j0 < 0 || j0 >= inputSize.width)
                            continue;

                        // проходимся по всей глубине тензора и считаем сумму
                        for (int c = 0; c < fd; c++)
                            sum += X(c, i0, j0) * W[f](c, i, j);
                    }
                }

                output(f, y, x) = sum; // записываем результат свёртки в выходной тензор
            }
        }
    }

    return output; // возвращаем выходной тензор
}

Этап 3. Обратное распространение ошибки

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

) и градиенты весовых коэффициентов (

и

).

Это становится вполне логичным, если рассматривать нейросеть как обыкновенную функцию с огромным количеством параметров, которую мы хотим минимизировать. А чтобы найти её минимум, нам нужно найти производные по каждому из весовых коэффициентов, чтобы затем шагнуть в направлении антиградиента (направление, в котором функция уменьшается). Поскольку нейросеть состоит из большого числа блоков, сходу тяжело предрассчитать производные, а потому на помощь приходит расчёт производной сложной функции: пусть у нас есть некоторая функция y = f(x) и функция g(y), и нам требуется найти производную по x функции z = g(f(x)). Для этого сначала нужно найти производную функции g, а затем умножить её на производную функции f: z’ = g'(f(x)) * f'(x).

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

Вычисление градиента по весовым коэффициентам

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

for (int f = 0; f < fc; f++)
    for (int y = 0; y < size.height; y++)
        for (int x = 0; x < size.width; x++)
            db[f] += deltas(f, y, x);

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

for (int f = 0; f < fc; f++) {
    for (int y = 0; y < size.height; y++) {
        for (int x = 0; x < size.width; x++) {
            double delta = deltas(f, y, x); // запоминаем значение градиента

            for (int i = 0; i < fs; i++) {
                for (int j = 0; j < fs; j++) {
                    int i0 = i + y - P;
                    int j0 = j + x - P;

                    // игнорируем выходящие за границы элементы
                    if (i0 < 0 || i0 >= inputSize.height || j0 < 0 || j0 >= inputSize.width)
                        continue;

                    // наращиваем градиент фильтра
                    for (int c = 0; c < fd; c++)
                        dW[f](c, i, j) += delta * X(c, i0, j0);
                }
            }
        }
    }
}

Вычисление градиента по входному тензору

Градиент по входному тензору считается обыкновенной свёрткой тензора дельт и весов с небольшим отличием: для вычисления фильтры поворачиваются на 180 градусов, а дополнение нулями заменяется на величину fs — 1 — P. Данную операцию называют транспонированной свёрткой или кросскорелляцией.

Tensor dX(inputSize);
int pad = fs - 1 - P;

for (int y = 0; y < inputSize.height; y++) {
    for (int x = 0; x < inputSize.width; x++) {
        for (int c = 0; c < fd; c++) {
            double sum = 0; // сумма для градиента

            // идём по всем весовым коэффициентам фильтров
            for (int i = 0; i < fs; i++) {
                for (int j = 0; j < fs; j++) {
                    int i0 = y + i - pad;
                    int j0 = x + j - pad;

                    // игнорируем выходящие за границы элементы
                    if (i0 < 0 || i0 >= size.height || j0 < 0 || j0 >= size.width)
                        continue;

                    // суммируем по всем фильтрам
                    for (int f = 0; f < fc; f++)
                        sum += W[f](c, fs - 1 - i, fs - 1 - j) * deltas(f, i0, j0); // добавляем произведение повёрнутых фильтров на дельты
                }
            }

            dX(c, y, x) = sum; // записываем результат в тензор градиента
        }
    }
}

Внимательный читаель мог заметить, что в заголовке метода использовался параметр dout, а в приведённом выше коде используется параметр deltas, а также неизвестный размер size. Действительно, deltas и dout не совсем одно и то же. Если шаг свёртки равен 1, то они совпадают, при другом шаге size и deltas вычисляются следующим образом:

TensorSize size; // размер дельт

// расчитываем размер для дельт
size.height = S * (outputSize.height - 1) + 1;
size.width = S * (outputSize.width - 1) + 1;
size.depth = outputSize.depth;

Tensor deltas(size); // создаём тензор для дельт

// расчитываем значения дельт
for (int d = 0; d < size.depth; d++)
    for (int i = 0; i < outputSize.height; i++)
        for (int j = 0; j < outputSize.width; j++)
            deltas(d, i * S, j * S) = dout(d, i, j);

Итоговый код для расчёта градиентов будет таким:

// обратное распространение
Tensor ConvLayer::Backward(const Tensor &dout, const Tensor &X) {
    TensorSize size; // размер дельт

    // расчитываем размер для дельт
    size.height = S * (outputSize.height - 1) + 1;
    size.width = S * (outputSize.width - 1) + 1;
    size.depth = outputSize.depth;

    Tensor deltas(size); // создаём тензор для дельт

    // расчитываем значения дельт
    for (int d = 0; d < size.depth; d++)
        for (int i = 0; i < outputSize.height; i++)
            for (int j = 0; j < outputSize.width; j++)
                deltas(d, i * S, j * S) = dout(d, i, j);

    // расчитываем градиенты весов фильтров и смещений
    for (int f = 0; f < fc; f++) {
        for (int y = 0; y < size.height; y++) {
            for (int x = 0; x < size.width; x++) {
                double delta = deltas(f, y, x); // запоминаем значение градиента

                for (int i = 0; i < fs; i++) {
                    for (int j = 0; j < fs; j++) {
                        int i0 = i + y - P;
                        int j0 = j + x - P;
                        
                        // игнорируем выходящие за границы элементы
                        if (i0 < 0 || i0 >= inputSize.height || j0 < 0 || j0 >= inputSize.width)
                            continue;

                        // наращиваем градиент фильтра
                        for (int c = 0; c < fd; c++)
                            dW[f](c, i, j) += delta * X(c, i0, j0);
                    }
                }

                db[f] += delta; // наращиваем градиент смещения
            }
        }
    }

    int pad = fs - 1 - P; // заменяем величину дополнения
    Tensor dX(inputSize); // создаём тензор градиентов по входу

    // расчитываем значения градиента
    for (int y = 0; y < inputSize.height; y++) {
        for (int x = 0; x < inputSize.width; x++) {
            for (int c = 0; c < fd; c++) {
                double sum = 0; // сумма для градиента

                // идём по всем весовым коэффициентам фильтров
                for (int i = 0; i < fs; i++) {
                    for (int j = 0; j < fs; j++) {
                        int i0 = y + i - pad;
                        int j0 = x + j - pad;

                        // игнорируем выходящие за границы элементы
                        if (i0 < 0 || i0 >= size.height || j0 < 0 || j0 >= size.width)
                            continue;

                        // суммируем по всем фильтрам
                        for (int f = 0; f < fc; f++)
                            sum += W[f](c, fs - 1 - i, fs - 1 - j) * deltas(f, i0, j0); // добавляем произведение повёрнутых фильтров на дельты
                    }
                }

                dX(c, y, x) = sum; // записываем результат в тензор градиента
            }
        }
    }

    return dX; // возвращаем тензор градиентов
}

Этап 4. Обновление весовых коэффициентов

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

// обновление весовых коэффициентов
void ConvLayer::UpdateWeights(double learningRate) {
    for (int index = 0; index < fc; index++) {
        for (int i = 0; i < fs; i++) {
            for (int j = 0; j < fs; j++) {
                for (int d = 0; d < fd; d++) {
                    W[index](d, i, j) -= learningRate * dW[index](d, i, j); // вычитаем градиент, умноженный на скорость обучения
                    dW[index](d, i, j) = 0; // обнуляем градиент фильтра
                }
            }
        }

        b[index] -= learningRate * db[index]; // вычитаем градиент, умноженный на скорость обучения
        db[index] = 0; // обнуляем градиент веса смещения
    }
}

Этап 5. Проверка

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

// установка веса фильтра по индексу
void ConvLayer::SetWeight(int index, int i, int j, int k, double weight) {
    W[index](i, j, k) = weight;
}

// установка веса смещения по индексу
void ConvLayer::SetBias(int index, double bias) {
    b[index] = bias;
}

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

Для начала создадим свёрточный слой, который будет принимать на вход тензор размером 5x5x3 (три канала 5×5), содержать 2 фильтра размером 3×3, шаг смещения 2 и единичное дополнение нулями. На выходе мы ожидаем тензор размером (5-3+2*1)/2+1 x (5-3+2*1)/2+1 x 2 = 3x3x2. Возьмём следующие фильтры и входной тензор для проверки:

      |-1 1 1 |  |  0 -1  1 |  |  1 -1  0 |
W[0]: |-1 1 1 |  | -1  0 -1 |  | -1  0 -1 |
      | 0 0 1 |  |  1  0  0 |  |  0  1 -1 |

      | 0  0 -1 |  |  1 -1  0 |  |  1  0  1 |
W[1]: | 1  0  0 |  | -1  1  0 |  |  1 -1  1 |
      | 0 -1  0 |  | -1  1  1 |  | -1  0  0 |

b[0]: 1
b[1]: 0

   | 1 2 0 1 0 |  | 1 2 2 1 2 |  | 0 2 0 1 1 |
   | 2 0 0 0 1 |  | 0 2 2 0 2 |  | 2 0 2 1 1 |
X: | 1 2 2 0 2 |  | 1 2 2 1 1 |  | 2 0 1 2 1 |
   | 2 2 2 0 1 |  | 2 2 0 1 0 |  | 0 1 0 1 2 |
   | 2 0 1 0 1 |  | 2 2 1 0 0 |  | 1 0 1 2 1 |        

Если всё сделано правильно, то на выходе должен получиться следующий тензор:

    | 2 -3 1 |  | 3 5  2 |
Y:  | 5 -7 2 |  | 1 0 -3 |
    | 5  0 0 |  |-2 4  3 |

Пусть теперь слой принимает тензоры размером 4x4x1 и содержит 1 фильтр 3х3 с единичным шагом без дополнения нулями.

      | 1 4 1 |
W[0]: | 1 4 3 |
      | 3 3 1 |

b[0]: 0

   | 4 5 8 7 |
X: | 1 8 8 8 |
   | 3 6 6 4 |
   | 6 5 7 8 |

Результат прямого распространения должен быть таким:

Y: | 122 148 |
   | 126 134 |

Запустим же теперь обратное распространение с такими дельтами:

dout: | 2 1 |
      | 4 4 |

В результате должны получить такой тензор:

    |  2  9  6  1 |
dX: |  6 29 30  7 |
    | 10 29 33 13 |
    | 12 24 16  4 |

Интерактивный пример

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

Размер:
Глубина:
Число фильтров:
Размер фильтров:
Шаг свёртки:
Дополнение нулями:

Итоги

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

Следующая часть: Свёрточная нейронная сеть с нуля. Часть 2. Слой подвыборки

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

  • Градиентный спуск
  • Функция ошибки
  • Метод обратного распространения ошибки
  • Пример расчета

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

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

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

Обучение нейронных сетей.

То есть на входы нейронов I1 и I2 мы подаем какие-либо числа, а на выходе сети получаем соответственно новое значение. При этом нам необходима некая выборка данных, включающая в себя значения входов и соответствующее им, правильное, значение на выходе:

bold{I_1} bold{I_2} bold{O_{net}}
x_{11} x_{12} y_{1}
x_{21} x_{22} y_{2}
x_{31} x_{32} y_{3}
x_{N1} x_{N2} y_{N}

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

bold{I_1} bold{I_2} bold{O_{net}}
1 4 5
2 7 9
3 5 8
1000 1500 2500

Эти значения и используются для обучения сети. Как именно — рассмотрим чуть ниже, пока сконцентрируемся на идее процесса в целом. Для того, чтобы иметь возможность тестировать работу сети в процессе обучения, исходную выборку данных делят на две части — обучающую и тестовую. Пусть имеется 1000 образцов, тогда можно 900 использовать для обучения, а оставшиеся 100 — для тестирования. Эти величины взяты исключительно ради наглядности и демонстрации логики выполнения операций, на практике все зависит от задачи, размер обучающей выборки может спокойно достигать и сотен тысяч образцов.

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

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

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

Анализируем нашу гипотетическую выборку:

Обучающая выборка.

Таким образом, для тестирования подаем на вход сети значения x_{(M+1)1}, x_{(M+1)2} и проверяем, чему равен выход, ожидаем очевидно значение y_{(M+1)}. Аналогично поступаем и для оставшихся тестовых образцов. После чего мы можем сделать вывод, успешно или нет работает сеть. Например, сеть дает правильный ответ для 90% тестовых данных, дальше уже встает вопрос — устраивает ли нас данная точность или процесс обучения необходимо повторить, либо провести заново, изменив какие-либо параметры сети.

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

Обучение нейронных сетей. Градиентный спуск.

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

Здесь Delta w_{ij} — величина, на которую необходимо изменить вес синапса, связывающего нейроны i и j нашей сети. Соответственно, зная это, необходимо на каждом этапе обучения производить корректировку весов связей между всеми элементами нейронной сети. Задача ясна, переходим к делу.

Пусть функция ошибки от веса имеет следующий вид:

Для удобства рассмотрим зависимость функции ошибки от одного конкретного веса:

График ошибки.

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

Минимизация ошибки при обучении нейронной сети.

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

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

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

Алгоритм обратного распространения ошибки.

Находясь в точке 1, целью является перейти в точку 2, поскольку в ней значение ошибки меньше (E_2 < E_1), а глобальная задача по-прежнему заключается в ее минимизации. Для этого необходимо изменить величину w на некое значение Delta w (Delta w = w_2 — w_1 > 0). При всем при этом в точке 1 градиент отрицательный. Фиксируем данные факты и переходим к точке 3, предположим, что мы находимся именно в ней.

Тогда для уменьшения ошибки наш путь лежит в точку 4, а необходимое изменение значения: Delta w = w_4 — w_3 < 0. Градиент же в точке 3 положителен. Этот факт также фиксируем.

А теперь соберем воедино эту информацию в виде следующей иллюстрации:

Переход bold{Delta w} Знак bold{Delta w} Градиент
1 rArr 2 w_2 — w_1 +
3 rArr 4 w_4 — w_3 +

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

Delta w = -alpha cdot frac{dE}{dw}

Имеем в наличии:

  • Delta w — величина, на которую необходимо изменить значение w.
  • frac{dE}{dw} — градиент в этой точке.
  • alpha — скорость обучения.

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

Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}}

Более того, вспомним о важном свойстве, которое мы отдельно пометили. И заключается оно в том, что величина градиента будет уменьшаться по мере приближения к минимуму функции. Что это нам дает? А то, что в том случае, если наша текущая дислокация далека от места назначения, то величина, корректирующая вес связи, будет больше. А это обеспечит скорейшее приближение к цели. При приближении к целевому пункту, величина frac{dE}{dw_{ij}} будет уменьшаться, что поможет нам точнее попасть в нужную точку, а кроме того, не позволит нам ее проскочить. Визуализируем вышеописанное:

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

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

Норма обучения.

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

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

Локальные минимумы при обучении нейронных сетей.

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

Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}} + gamma cdot Delta w_{ij}^{t - 1}

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

Итого, резюмируем продвижение к цели:

  • Нашей задачей было найти закон, по которому необходимо изменять величину весов связей между нейронами.
  • Наш результат — Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}} + gamma cdot Delta w_{ij}^{t — 1} — именно то, что и требовалось 👍

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

Обучение нейронных сетей. Функция ошибки.

Начнем с того, что определимся с тем, что у нас в наличии, для этого вернемся к конкретной нейронной сети. Пусть вид ее таков:

Пример нейронной сети.

Интересует нас, в первую очередь, часть, относящаяся к нейронам выходного слоя. Подав на вход определенные значения, получаем значения на выходе сети: O_{net, 1} и O_{net, 2}. Кроме того, поскольку мы ведем речь о процессе обучения нейронной сети, то нам известны целевые значения: O_{correct, 1} и O_{correct, 2}. И именно этот набор данных на этом этапе является для нас исходным:

  • Известно: O_{net, 1}, O_{net, 2}, O_{correct, 1} и O_{correct, 2}.
  • Необходимо определить величины Delta w_{ij} для корректировки весов, для этого нужно вычислить градиенты (frac{dE}{dw_{ij}}) для каждого из синапсов.

Полдела сделано — задача четко сформулирована, начинаем деятельность по поиску решения.

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

E_k = O_{correct, k} - O_{net, k}

Дополним пример числовыми значениями:

Нейрон bold{O_{net}} bold{O_{correct}} bold{E}
1 0.9 0.5 -0.4
2 0.2 0.6 0.4

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

E_{sum} = e_1 + e_2 = -0.4 + 0.4 = 0

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

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

E_k = | O_{correct, k} - O_{net, k} |

Тут в действие вступает уже проблема иного рода:

График модуля.

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

В итоге хороший результат дает зависимость (для выходного нейрона под номером k):

E_k = (O_{correct, k} - O_{net, k})^2

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

Краткий вывод промежуточного шага, на который мы вышли:

  • Имеющееся: frac{dE}{dw_{jk}} = frac{d}{d w_{jk}}(O_{correct, k} — O_{net, k})^2.
  • Искомое по-прежнему: Delta w_{jk}.

Несложные диффернциально-математические изыскания выводят на следующий результат:

frac{dE}{d w_{jk}} = -(O_{correct, k} - O_{net, k}) cdot f{Large{prime}}(sum_{j}w_{jk}O_j) cdot O_j

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

Освежим в памяти структуру сети:

Пример обучения нейронных сетей.

Формулу можно упростить, сгруппировав отдельные ее части:

  • (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(sum_{j}w_{jk}O_j) — ошибка нейрона k.
  • O_j — тут все понятно, выходной сигнал нейрона j.

f{Large{prime}}(sum_{j}w_{jk}O_j) — значение производной функции активации. Причем, обратите внимание, что sum_{j}w_{jk}O_j — это не что иное, как сигнал на входе нейрона k (I_{k}). Тогда для расчета ошибки выходного нейрона: delta_k = (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(I_k).

Итог: frac{dE}{d w_{jk}} = -delta_k cdot O_j.

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

f{'}(x) = f(x)medspace (1medspace-medspace f(x))

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

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

frac{dE}{d w_{ij}} = -delta_j cdot O_i

Который примет следующий вид:

delta_j = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j)

То есть ошибка для элемента слоя j получается путем взвешенного суммирования ошибок, «приходящих» к нему от нейронов следующего слоя и умножения на производную функции активации. В результате:

frac{dE}{d w_{ij}} = -(sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j) cdot O_i

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

  • Ошибка:
    • выходной слой: delta_k = (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(I_k)
    • скрытые слои: delta_j = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j)
  • Градиент: frac{dE}{d w_{ij}} = -delta_j cdot O_i
  • Корректировка весовых коэффициентов: Delta w_{ij} = -alpha cdot frac{dE}{dw_{ij}} + gamma cdot Delta w_{ij}^{t — 1}

Преобразуем последнюю формулу:

Delta w_{ij} = alpha cdot delta_j cdot O_i + gamma cdot Delta w_{ij}^{t - 1}

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

Метод обратного распространения ошибки.

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

Суть же метода подразумевает наличие двух этапов:

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

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

Вернемся к конкретному примеру для явной демонстрации этих принципов:

Обратное распространение ошибки.

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

Процесс обучения нейронной сети для алгоритма обратного распространения ошибки будет таким:

  1. Прямой проход. Подаем на вход значения I_1, I_2, I_3 из обучающей выборки. В результате работы сети получаем выходные значения O_{net, 1}, O_{net, 2}. Этому целиком и полностью был посвящен предыдущий манускрипт.
  2. Рассчитываем величины ошибок для всех слоев:
    • для выходного: delta_k = (O_{correct, k} — O_{net, k}) cdot f{Large{prime}}(I_k)
    • для скрытых: delta_j = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_j)
  3. Далее используем полученные значения для расчета Delta w_{ij} = alpha cdot delta_j cdot O_i + gamma cdot Delta w_{ij}^{t — 1}
  4. И финишируем, рассчитывая новые значения весов: w_{ij medspace new} = w_{ij} + Delta w_{ij}
  5. На этом один цикл обучения закончен, данные шаги 1 — 4 повторяются для других образцов из обучающей выборки.

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

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

Пример расчетов для метода обратного распространения ошибки.

Возьмем нейронную сеть и зададим начальные значения весов:

Пример расчетов для метода обратного распространения ошибки.

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

В качестве функции активации используем сигмоиду:

f(x) = frac{1}{1 + e^{-x}}

И ее производная:

f{Large{prime}}(x) = f(x)medspace (1medspace-medspace f(x))

Берем один образец из обучающей выборки, пусть будут такие значения:

  • Входные: I_1 = 0.6, I_1 = 0.7.
  • Выходное: O_{correct} = 0.9.

Скорость обучения alpha пусть будет равна 0.3, момент — gamma = 0.1. Все готово, теперь проведем полный цикл для метода обратного распространения ошибки, то есть прямой проход и обратный.

Прямой проход.

Начинаем с выходных значений нейронов 1 и 2, поскольку они являются входными, то:

O_1 = I_1 = 0.6 \
O_2 = I_2 = 0.7

Значения на входе нейронов 3, 4 и 5:

I_3 = O_1 cdot w_{13} + O_2 cdot w_{23} = 0.6 cdot (-1medspace) + 0.7 cdot 1 = 0.1 \
I_4 = 0.6 cdot 2.5 + 0.7 cdot 0.4 = 1.78 \
I_5 = 0.6 cdot 1 + 0.7 cdot (-1.5medspace) = -0.45

На выходе этих же нейронов первого скрытого слоя:

O_3 = f(I3medspace) = 0.52 \
O_4 = 0.86\
O_5 = 0.39

Продолжаем аналогично для следующего скрытого слоя:

I_6 = O_3 cdot w_{36} + O_4 cdot w_{46} + O_5 cdot w_{56} = 0.52 cdot 2.2 + 0.86 cdot (-1.4medspace) + 0.39 cdot 0.56 = 0.158 \
I_7 = 0.52 cdot 0.34 + 0.86 cdot 1.05 + 0.39 cdot 3.1 = 2.288 \
O_6 = f(I_6) = 0.54 \
O_7 = 0.908

Добрались до выходного нейрона:

I_8 = O_6 cdot w_{68} + O_7 cdot w_{78} = 0.54 cdot 0.75 + 0.908 cdot (-0.22medspace) = 0.205 \
O_8 = O_{net} = f(I_8) = 0.551

Получили значение на выходе сети, кроме того, у нас есть целевое значение O_{correct} = 0.9. То есть все, что необходимо для обратного прохода, имеется.

Обратный проход.

Как мы и обсуждали, первым этапом будет вычисление ошибок всех нейронов, действуем:

delta_8 = (O_{correct} - O_{net}) cdot f{Large{prime}}(I_8) = (O_{correct} - O_{net}) cdot f(I_8) cdot (1-f(I_8)) = (0.9 - 0.551medspace) cdot 0.551 cdot (1-0.551medspace) = 0.0863 \
delta_7 = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_7) = (delta_8 cdot w_{78}) cdot f{Large{prime}}(I_7) = 0.0863 cdot (-0.22medspace) cdot 0.908 cdot (1 - 0.908medspace) = -0.0016 \
delta_6 = 0.086 cdot 0.75 cdot 0.54 cdot (1 - 0.54medspace) = 0.016 \
delta_5 = (sum_{k}{}{delta_kmedspace w_{jk}}) cdot f{Large{prime}}(I_5) = (delta_7 cdot w_{57} + delta_6 cdot w_{56}) cdot f{Large{prime}}(I_7) = (-0.0016 cdot 3.1 + 0.016 cdot 0.56) cdot 0.39 cdot (1 - 0.39medspace) = 0.001 \
delta_4 = (-0.0016 cdot 1.05 + 0.016 cdot (-1.4)) cdot 0.86 cdot (1 - 0.86medspace) = -0.003 \
delta_3 = (-0.0016 cdot 0.34 + 0.016 cdot 2.2) cdot 0.52 cdot (1 - 0.52medspace) = -0.0087

С расчетом ошибок закончили, следующий этап — расчет корректировочных величин для весов всех связей. Для этого мы вывели формулу:

Delta w_{ij} = alpha cdot delta_j cdot O_i + gamma cdot Delta w_{ij}^{t - 1}

Как вы помните, Delta w_{ij}^{t — 1} — это величина поправки для данного веса на предыдущей итерации. Но поскольку у нас это первый проход, то данное значение будет нулевым, соответственно, в данном случае второе слагаемое отпадает. Но забывать о нем нельзя. Продолжаем калькулировать:

Delta w_{78} = alpha cdot delta_8 cdot O_7 = 0.3 cdot 0.0863 cdot 0.908 = 0.0235 \
Delta w_{68} = 0.3 cdot 0.0863 cdot 0.54= 0.014 \
Delta w_{57} = alpha cdot delta_7 cdot O_5 = 0.3 cdot (−0.0016medspace) cdot 0.39= -0.00019 \
Delta w_{47} = 0.3 cdot (−0.0016medspace) cdot 0.86= -0.0004 \
Delta w_{37} = 0.3 cdot (−0.0016medspace) cdot 0.52= -0.00025 \
Delta w_{56} = alpha cdot delta_6 cdot O_5 = 0.3 cdot 0.016 cdot 0.39= 0.0019 \
Delta w_{46} = 0.3 cdot 0.016 cdot 0.86= 0.0041 \
Delta w_{36} = 0.3 cdot 0.016 cdot 0.52= 0.0025 \
Delta w_{25} = alpha cdot delta_5 cdot O_2 = 0.3 cdot 0.001 cdot 0.7= 0.00021 \
Delta w_{15} = 0.3 cdot 0.001 cdot 0.6= 0.00018 \
Delta w_{24} = alpha cdot delta_4 cdot O_2 = 0.3 cdot (-0.003medspace) cdot 0.7= -0.00063 \
Delta w_{14} = 0.3 cdot (-0.003medspace) cdot 0.6= -0.00054 \
Delta w_{23} = alpha cdot delta_3 cdot O_2 = 0.3 cdot (−0.0087medspace) cdot 0.7= -0.00183 \
Delta w_{13} = 0.3 cdot (−0.0087medspace) cdot 0.6= -0.00157

И самый что ни на есть заключительный этап — непосредственно изменение значений весовых коэффициентов:

w_{78 medspace new} = w_{78} + Delta w_{78} = -0.22 + 0.0235 = -0.1965 \
w_{68 medspace new} = 0.75+ 0.014 = 0.764 \
w_{57 medspace new} = 3.1 + (−0.00019medspace) = 3.0998\
w_{47 medspace new} = 1.05 + (−0.0004medspace) = 1.0496\
w_{37 medspace new} = 0.34 + (−0.00025medspace) = 0.3398\
w_{56 medspace new} = 0.56 + 0.0019 = 0.5619 \
w_{46 medspace new} = -1.4 + 0.0041 = -1.3959 \
w_{36 medspace new} = 2.2 + 0.0025 = 2.2025 \
w_{25 medspace new} = -1.5 + 0.00021 = -1.4998 \
w_{15 medspace new} = 1 + 0.00018 = 1.00018 \
w_{24 medspace new} = 0.4 + (−0.00063medspace) = 0.39937 \
w_{14 medspace new} = 2.5 + (−0.00054medspace) = 2.49946 \
w_{23 medspace new} = 1 + (−0.00183medspace) = 0.99817 \
w_{13 medspace new} = -1 + (−0.00157medspace) = -1.00157\

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

Понравилась статья? Поделить с друзьями:
  • Обратное распространение ошибки во времени
  • Обратное распространение ошибки python код
  • Обратно робин бежал еще быстрее есть ли грамматическая ошибка
  • Обратно проливной дождь пошел найти ошибку
  • Обратно пошел сильный дождь ошибка