Использование нейронных сетей для распознавания рукописных цифр

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

Рукописные цифры

Большинство людей безо всяких усилий поймёт, что это 504192. Но эта простота обманчива. В каждом полушарии нашего мозга есть первичная зрительная кора, также называемая V1, которая содержит 140 млн нейронов и десятки миллиардов соединений между ними. При этом зрение задействует отнюдь не одну эту область, а дополнительно вовлекают целый набор областей, связанных с визуальным восприятием — области V2, V3, V4 и V5, каждая из которых решает всё более сложную задачу обработки изображения. В своей голове мы носим суперкомпьютер, который эволюция настраивала сотни миллионов лет, добившись превосходной работы визуального восприятия. В действительности распознать цифры написанные от руки — колоссально сложная задача. Мы этого не замечаем в первую очередь благодаря тому, что в процессе эволюции мы, люди, чрезвычайно преуспели в способности распознавать то, что видит наш глаз. Но почти вся эта работа производится подсознательно. Что в итоге и скрывает истинную сложность задачи, которую решает наш мозг.

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

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

100 цифр набора MNIST

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

В данной главе мы создадим нейронную сеть которая будет обучаться распознавать рукописные цифры. Программа займёт всего лишь 74 строки кода и не будет использовать никаких сторонних библиотек, связанных с нейронными сетями. Однако наша маленькая программка будет способна распознавать цифры с точностью выше 96% без какой-либо помощи со стороны человека. В следующих главах мы затронем подходы, которые могут довести точность вплоть до 99%. В реальности, лучшие промышленные реализации нейронных сетей уже настолько хороши, что повсюду используются для обработки чеков и распознавания адресов.

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

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

Перцептроны

Что такое нейронная сеть? Начнём с того, что я расскажу вам про вид искусственного нейрона, называемого перцептроном. Перцептроны были разработаны в 50-х и 60-х годах Фрэнком Розенблаттом, вдохновленным более ранней работой Уоррена МакКаллока и Уолтера Питтса. В нынешние времена более распространены другие модели искусственных нейронов — в этой книге, а также многих других работах по нейронным сетям, основная используемая модель нейрона это так называемый сигмоидный нейрон. Мы скоро доберёмся и до него. Однако для того чтобы понять почему сигмоидные нейроны определены именно таким образом, стоит потратить немного времени на разбор перцептронов.

Так как же работают перцептроны? Перцептрон принимает на вход несколько двоичных значений $x_1,x_2,\ldots$ и даёт на выходе единственное двоичное значение:

Схема перцептрона

В примере выше перцептрон имеет три входа $x_1, x_2, x_3$. А в общем случае он может иметь как и больше так и меньше входов. Розенблатт предложил простое правило вычисления выходного значения. Он добавил веса $w_1,w_2,\ldots$ — вещественные числа выражающие важность соответствующих входных значений по отношению к выходному значению. Выходное значение нейрона, 0 или 1, определяется тем является ли взвешенная сумма $\sum_j w_j x_j$ меньше или больше чем некое пороговое значение. Так же как и веса, пороговое значение это вещественное число, которое является параметром нейрона. Запишем в математических терминах: $$ \begin{eqnarray} \mbox{output} = \left\{ \begin{array}{ll} 0 \mbox{ if } \sum_j w_j x_j \leq \mbox{ threshold} \\ 1 \mbox{ if } \sum_j w_j x_j > \mbox{ threshold} \end{array} \right. \label{1}\end{eqnarray} $$

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

  1. Хорошая ли будет погода?
  2. Пойдёт ли с вами друг/подруга?
  3. Можно ли добраться туда общественным транспортом? (у вас нет машины)

Мы можем представить эти три фактора через соответствующие двоичные переменные $x_1, x_2, и x_3$. К примеру, $x_1 = 1$ если погода хорошая, и $x_1 = 0$ если погода плохая. Схоже, $x_2 = 1$ если друг или подруга хочет пойти с вами, и $x_2 = 0$ если не хочет. И аналогично для $x_3$ и общественного транспорта.

Итак, предположим, вы так обожаете сыр, что рады ехать даже если ваш друг не проявил желания вас сопроводить и туда трудно добраться транспортом. Но, возможно, при этом вы терпеть не можете плохую погоду и не выходите из дома в таких случаях. Всё это можно представить в перцептронной модели без особого труда. Один из способов: выбрать вес $w_1 = 6$ для погодного фактора, $w_2 = 2$ и $w_3 = 2$ для оставшихся. Большее значение $w_1$ значит, что погода очень важна для вас, гораздо больше чем то, пойдёт ли с вами еще кто-то еще или ездит ли туда транспорт. В заключение, выберем значение 5 в качестве порогового для перцептрона. При таких выбранных значениях, перцептрон соответствует желаемой модели принятия решений, выдавая 1 если погода хорошая и 0 если не очень. Ему совершенно безразлично пойдут ли с вами друзья и как обстоят дела с транспортом.

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

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

Сложная сеть перцептронов

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

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

Давайте теперь упростим наше описание перцептрона. Условие вида $\sum_j w_j x_j > \mbox{threshold}$ довольно громоздко, поэтому мы внесем пару изменений в обозначениях. Первое: перепишем сумму $\sum_j w_j x_j$ в виде скалярного произведения $w \cdot x \equiv \sum_j w_j x_j$, где $w$ и $x$ — векторы, компоненты которых являются весами и входными значениями соответственно. Второе — перенесём пороговое значение в левую часть неравенства и обозначим через $b \equiv -\mbox{threshold}$ — величину, называемую смещением. Подставив смещение вместо порогового значения, получим такие правила работы перцептрона: $$ \begin{eqnarray} \mbox{output} = \left\{ \begin{array}{ll} 0 \mbox{ if } w\cdot x + b \leq 0 \\ 1 \mbox{ if } w\cdot x + b > 0 \end{array} \right. \label{2}\end{eqnarray} $$

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

Я описал перцептрон как некий метод принятия решений на основе входных данных. Также можно использовать перцептроны для вычисления простейших логических функций И, ИЛИ, И-НЕ, задействованных при любых вычислениях. Например, пусть у нас есть перцептрон с двумя входами, каждый с весом -2 и общим смещением 3. Вот он:

Перцептрон с двумя входами

Тогда (0,0) на входе даёт 1 на выходе, поскольку $(-2)*0+(-2)*0+3 = 3$ положительно. Я использую значок $*$ для того чтобы умножение в этом примере выглядело более явным. Сходные вычисления приводят к тому что (0,1) и (1,0) выдают на выходе 1. Однако (1,1) на входе выдаёт 0, поскольку $(-2)*1+(-2)*1+3 = -1$ отрицательно. Получается что наш перцептрон работает как логический элемент И-НЕ.

Этот пример демонстрирует, что мы можем использовать перцептрон для вычисления простых логических функций. А значит можно использовать сети перцептронов для вычисления вообще любых функций. Причина состоит в том, что логический элемент И-НЕ является универсальным для вычислений, что значит мы можем построить любое вычисление на основе этих элементов. Например, мы можем использовать элементы И-НЕ для построения логической схемы, вычисляющей сумму двух битов, $x_1$ и $x_2$. Это требует вычисления побитовой суммы $x_1 \oplus x_2$ а также бита переноса который устанавливается в 1 если оба $x_1$ и $x_2$ равны 1, то есть это побитовое произведение $x_1$ и $x_2$:

Логическая схема сумматора

Чтобы добиться эквивалентной сети из перцептронов нужно всего лишь заменить все И-НЕ элементы на перцептроны с двумя входами, каждый с весом -2 и общим смещением 3. На рисунке ниже — получившаяся сеть перцептронов. Стоит отметить,что на рисунке один из перцептронов слегка смещен, чтобы было легче читать диаграмму:

Эквивалентная сеть перцептронов

Важная деталь — выходное значения левого перцептрона используется дважды в качестве входного значения нижнего перцептрона. Ранее, при определении модели перцептрона, мы не описывали отдельно возможность такого подключения. Это вообще говоря, не имеет особого значения, так как при желании мы можем несколько модифицировать схему, превратив два этих входа в один и удвоив соответствующий ему вес. Это значит вместо двух весов со значениями -2, мы будем использовать один вес со значением -4. (Если для вас это не очевидно, выполните самостоятельно доказательство эквивалентности этих двух случаев). После наших изменений схема принимает такой вид (все необозначенные веса равны -2, все смещения равны 3, а единственный вес нижнего перцептрона равен -4):

Модифицированная сеть перцептронов

До настоящего момента я изображал входы типа $x_1$ и $x_2$ как переменные входящие с левой стороны в первый слой перцептронов. В действительности принято обозначать их как отдельный слой перцептронов (входной слой):

Отдельный слой входных перцептронов

Такое обозначение входных перцептронов, у которых есть только выходное значение, но нет входных,

Входной перцептрон

— просто удобное сокращение. Можно подумать, что это обычные перцептроны без входных значений. Если бы это было так, то взвешенная сумма $\sum_j w_j x_j$ была бы всегда 0, и перцептрон всегда бы выдавал 1 если b>0 и 0 если b<0. Т.е. такой перцептрон просто выдает фиксированное значение, а не заданное значение ($x_1$ в примере выше). Можно считать, что входные перцептроны совсем не перцептроны, а некие особые ячейки, которые просто задаются определенными выходными значениями $x_1, x_2,\ldots$.

Пример сумматора демонстрирует, что сеть перцептронов может использоваться для симуляции схемы из элементов И-НЕ. И поскольку И-НЕ это универсальные вычислительные элементы, то значит и перцептроны являются универсальными вычислительными элементами.

Вычислительная универсальность перцептронов одновременно обнадеживает и расстраивает. Обнадеживает — поскольку выходит, что перцептроны могут быть настолько же мощными как и любые другие вычислительные устройства. Расстраивает — поскольку создаёт ощущение, что перцептроны — просто-напросто новый тип элементов И-НЕ. Это не очень впечатляет!

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

Сигмоидные нейроны

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

Схема элементарной сети

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

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

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

Хорошо, дадим определение сигмоидному нейрону. Обозначать будем их так же, как перцептроны:

Обозначение нейрона

Так же как и у перцептрона, у сигмоидного нейрона присутствует некоторое количество входов. Однако, в отличие от перцептрона, входы которого могут принимать только дискретные значение — 0 и 1, входы нашего нового нейрона могут иметь любое значение между 0 и 1. Так, например, 0.638…. — вполне допустимое значение для входа сигмоидного нейрона. Так же как и у перцептрона, у сигмоидного нейрона определены веса $w_1, w_2,\ldots$ и смещение $b$. А вот значение выхода уже не дискретное. А именно, вместо 1 или 0 выходное значение будет определяться выражением $\sigma(w \cdot x+b)$, где $\sigma$ — сигмоида*, заданная следующим образом: $\begin{eqnarray} \sigma(z) \equiv \frac{1}{1+e^{-z}}. \label{3}\end{eqnarray}$

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

Записывая это чуточку более явно, выражение для сигмоидного нейрона со входами $x_1, x_2, \dots$ и смещением $b$ будет выглядеть так: $\begin{eqnarray} \frac{1}{1+\exp(-\sum_j w_j x_j-b)}. \label{4}\end{eqnarray}$

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

Чтобы понять сходство с перцептронной моделью, предположим что $z \equiv w \cdot x + b$ большое положительное число. Тогда $e^{-z} \approx 0$ и, следовательно, $\sigma(z) \approx 1$. Другими словами, когда $z = w \cdot x+b$ принимает большие положительные значения — выходной сигнал сигмоидного нейрона, так же, как и в случае с перцептроном, будет приблизительно равен 1. Теперь давайте предположим, что $z = w \cdot x+b$ отрицательно и велико по модулю. В этом случае $e^{-z}\rightarrow \infty$ и $\sigma(z) \approx 0$. Так что при таких значениях выражения $z = w \cdot x+b$ поведение нового нейрона приблизительно совпадает с поведением перцептрона.

Что можно сказать о математической записи $\sigma$? Как её можно интерпретировать? Вообще то, точная запись $\sigma$ не так уж важна — гораздо важнее форма её графика:

Сигмоида

Эта форма сигмоиды — сглаженная версия ступенчатой функции:

Ступенька

Если бы мы заменили $\sigma$ на ступенчатую функцию, то сигмоидный нейрон превратился бы в перцептрон, поскольку выходное значение будет 0 или 1 в зависимости от того $w\cdot x+b$ положительно или отрицательно. Используя же настоящую сигмоиду, как уже говорилось выше, мы получим “сглаженную” версию перцептрона. В действительности, важна именно гладкость сигмоиды, а не её точная форма. Гладкость сигмоиды означает, что малые изменения $\Delta w_j$ весов и $\Delta b$ в смещениях вызовут малые изменения $\Delta \mbox{output}$ на выходе нейрона.

Замечание: когда $w\cdot x+b=0$ перцептрон выдаёт 0, в то время как ступенчатая функция выдаёт 1. Поэтому, строго говоря, нужно подправить значение функции в этой точке. Но идею вы поняли.

Из основ курса математического анализа мы знаем, что это $\Delta\mbox{output}$ можно приблизить выражением $$ \begin{eqnarray} \Delta \mbox{output} \approx \sum_j \frac{\partial \, \mbox{output}}{\partial w_j} \Delta w_j + \frac{\partial \, \mbox{output}}{\partial b} \Delta b, \label{5}\end{eqnarray} $$ где суммирование происходит по всем весам $w_j$, а выражения $\partial \, \mbox{output} / \partial w_j$ и $\partial \, \mbox{output} /\partial b$ обозначают частные производные выходного значения по $w_j$ и $b$ соответственно. Не волнуйтесь если вам не знакомы частные производные! Хотя выражение сверху и выглядит сложным, со всеми этими производными, оно сводится по сути к простому утверждению: $\Delta output$ это линейная функция изменений $\Delta w_j$ и $\Delta b$ весов и смещений. Эта линейность позволяет с лёгкостью выбирать такие малые изменения весов и смещений, добиваясь желаемых малых изменений выходного значения. Несмотря на сходство поведения сигмоидных нейронов с перцептронами, их выходные значения гораздо лучше управляются изменениями весов и смещений.

Если для нас первостепенную важность имеет не конкретная запись выражения для $\sigma$, а скорее общее поведение функции и ее форма — почему же тогда мы в выражении $\eqref{3}$ используем именно такое выражение? Надо признаться, что по мере изложения мы время от времени будем рассматривать нейроны и с другими активационными функциями. Основным изменением в таких случаях будет изменение выражений с частными производными в формуле $\eqref{5}$. Сам вид функции $\sigma$ будет нам очень полезен при взятии частных производных, которые очень просто вычисляются для экспоненты. В любом случае, сигмоида очень часто используется при работе с нейронными сетями и в этой книге обычно будет использоваться именно она.

Как можно было бы интерпретировать выходное значение сигмоидного нейрона? Очевидная особенность, отличающая перцетрон от сигмоидного нейрона — в том, что набор значений на выходе сигмоидного нейрона не ограничивается 0 и 1. Мы можем получить любое вещественное число между 0 и 1; так, например, 0.173 и 0.689 — вполне возможные варианты. Такое положение дел может быть вполне удобным, к примеру, если мы решим найти среднюю яркость пикселей входного изображения, подаваемого на вход нейронной сети. Но порой это может вызвать неприятности. Предположим мы хотим чтобы выход нейронной сети показывал является ли изображение “9” или не является “9”. Очевидно, проще всего использовать двоичную величину — 0 или 1, как в перцептроне. Однако, на деле мы можем условиться считать, к примеру, что значение больше или равное 0.5 — это “9”, а меньше — это не “9”. Я постараюсь всегда явно указывать когда мы используем такие соглашения, поэтому проблем возникнуть не должно.

Упражнения

  1. Симулятор перцепторонов, ч.1. Предположим мы возьмём все веса и смещения в сети перцептронов и домножим их на положительную константу $c>0$. Покажите, что поведение сети никак не изменится.
  2. Симулятор перцептронов, ч.2. Пусть у нас есть те же условия, что и в предыдущей задаче. Предположим также, что общие входные данные заранее известны. Нам не нужна точная их величина, просто будем считать, что они зафиксированы. Предположим, что веса и смещения таковы, что $w \cdot x +b \neq 0$ для входного $x$ любого из перцептронов в сети. Теперь заменим все перцептроны в сети на сигмоидные нейроны и домножим веса и смещения на положительную константу $c>0$. Покажите, что в пределе, когда $c \rightarrow \infty$, поведение сети сигмоидных нейронов в точности совпадёт с поведением сети перцептронов. Почему это условие нарушится, если $w \cdot x + b =0$ хотя бы для одного перцептрона?

Архитектура нейронных сетей

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

Трехслойная нейросеть

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

Четырехслойная нейросеть

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

Проектирование входных и выходных слоёв в сети зачастую довольно прямолинейно. Например, представим, что мы снова пытаемся определить, является ли рукописное изображение “9” или нет. Естественным решением было бы записывать интенсивности пикселей изображения во входные нейроны. Если изображение определяется 64 x 64 = 4 096 монохромными пикселями, то и входных нейронов у нас должно быть 4 096, на которые подаются значения яркостей пикселей, которые масштабируются таким образом, чтобы принимать значения от 0 до 1. Выходной слой в нашем случае будет содержать единственный нейрон, который будет выдавать значение, в зависимости от величины которого мы будем решать, считать ли изображение девяткой или нет: к примеру, значения меньше 0.5 мы можем трактовать как сигнал, что изображение не содержит 9, если же сигнал больше 0.5, будем считать, что сеть обнаружила девятку.

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

До сих пор, мы обсуждали нейронные сети, где выход одного слоя использовался в качестве входа следующего слоя. Такие сети называются нейронными сетями прямого распространения. Это означает, что в таких сетях нет циклов — информация всегда распространяется вперёд, никогда не попадая на предыдущие нейроны. Если бы у нас были такие циклы, то возникла бы ситуация, когда вход $\sigma$ функции зависел бы от её выхода. Иметь дело с такой структурой сложнее, поэтому не будем подробно рассматривать такие циклы в нейронных сетях.

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

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

Простая сеть для классификации рукописных цифр

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

Цифры MNIST

разбить на шесть отдельных изображений:

Отдельные цифры

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

Первая цифра MNIST как "5".

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

С этой целью построим трехслойную нейронную сеть:

Трехслойная нейронная сеть

Входной слой сети будет содержать информацию о яркостях пикселей. Тренировочные данные для нашей сети будут состоять из большого числа отсканированных изображений размера 28 x 28 с рукописными цифрами. Таким образом, входной слой должен содержать $784 = 28 \times 28$ нейронов. Наглядности ради, на иллюстрации я не стал изображать все 784 входных нейрона. Входные яркости кодируют черно-белое изображение, при этом значение 0.0 соответствует белому цвету, а значение 1.0 — чёрному, промежуточные значения определяют соответствующие яркости оттенков серого цвета.

Второй слой сети — это скрытый слой. Мы обозначим количество нейронов в этом скрытом слое как n, и будем экспериментировать с его значениями. Картинка выше иллюстрирует маленький скрытый слой всего из n=15 нейронов.

Выходной слой содержит 10 нейронов. Если первый нейрон активируется, т.е. выдает значение близкое к 1, то значит сеть распознала “0”. Если второй нейрон активируется, то значит сеть распознала “1”. И так далее. Говоря чуточку строже, мы пронумеровали выходные нейроны от 0 до 9 и определяем, какой из этих нейронов имеет максимальное значение активации. Если нейрон, например, имеет номер 6, то значит, наша сеть считает, что это цифра “6”. И так далее для других выходных нейронов.

Вы можете задастся вопросом — зачем использовать аж 10 выходных нейронов, тогда как задача сети по сути сопоставить входному изображению распознанную цифру от 0 до 9? Может показаться более естественным использовать только 4 выходных нейрона, интерпретируя выход каждого нейрона как разряд в двоичной записи числа, округляя его до 0 или 1. Четыре нейрона как раз достаточно чтобы закодировать цифру, поскольку $2^4 = 16$ даже больше, чем 10 возможных цифр. Зачем же тогда использовать 10 нейронов? Разве это эффективно? Обоснование тут эмпирическое: мы можем попробовать оба варианта, и как окажется на практике, для этой конкретной задачи сеть с 10 выходными нейронами обучается распознавать цифры лучше чем сеть с 4 выходными нейронами. Но тогда возникает естественный вопрос — так почему же 10 выходных нейронов лучше? Есть ли какое-то интуитивное объяснение тому, что почему-то нам выгодно использовать 10 выходных нейронов вместо 4?

Чтобы понять почему так происходит, сначала нам следует вспомнить что же делает нейронная сеть на фундаментальном уровне. Рассмотрим сначала случай, когда используется 10 выходных нейронов. Сконцентрируем своё внимание на первом выходном нейроне, который пытается определить является ли цифра нулём. Он делает это рассчитывая сумму выходных значений слоя скрытых нейронов с весами. А что делают эти самые скрытые нейроны? Предположим, к примеру, что первый нейрон скрытом слое распознает похоже ли изображение на представленное ниже (нечто типа диагональной черты в левом верхнем квадранте изображения):

Левый верхний штрих

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

Остальные штрихи

Как вы, возможно, уже догадались вместе они образуют цифру “0”, которую мы ранее видели во входном наборе:

Полный ноль

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

Предполагая, что нейронная сеть работает именно таким образом, мы можем дать разумное объяснение, почему 10 выходных нейронов действительно лучше, чем 4. Если бы их было 4, то первый выходной нейрон пытался бы найти наибольший бит в двоичной записи цифры. И совершенно не очевидно как мы можем его соотнести с простыми паттернами наподобие вышеописанных! Вообще, сложно представить чтобы визуальные очертания цифр были как-то исторически связаны с двоичной записью числа.

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

Упражнения

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

Сеть с дополнительным слоем

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

Теперь, когда мы спроектировали нашу нейронную сеть, как мы обучим ее распознавать цифры? Первым делом нам нужны данные для обучения — так называемый набор тренировочных данных. Мы будем пользоваться набором MNIST, содержащим десятки тысяч отсканированных изображений рукописных цифр вместе с их корректными классификациями. Название MNIST связано с тем, что этот набор представляет собой модификацию двух наборов данных, изначально собранных институтом NIST, Национальным институтом стандартов и технологий США. Вот примеры изображений оттуда:

Примеры изображений

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

Данные MNIST разбиты на две части. Первая часть содержит 60 000 изображений, используемых как тренировочные данные. Изображения — это отсканированные образцы рукописного текста 250 человек, половина из которых были сотрудники Бюро переписи населения США, а половина — ученики старших классов. Изображения черно-белые и имеют размер 28 на 28 пикселей. Вторая часть набора данных — это 10 000 изображений, используемых как тестовые данные. Опять-таки, это черно-белые картинки 28 на 28 пикселей. Мы будем использовать тестовые данные чтобы оценить насколько хорошо нейронная сеть справляется с поставленной задачей. Для того, чтобы это тестирование сети было адекватным, в тестовом и тренировочном наборах использовались образцы цифр, написанные разными группами людей. Хотя и в том и другом случае в этих группах были как сотрудники бюро, так и школьники. Это дает нам уверенность, что наша система может распознавать цифры людей, чей почерк видит впервые.

Мы обозначим через $x$ входные тренировочные данные. Удобно считать, что каждый тренировочный пример $x$ — это $28 \times 28 = 784$-мерный вектор. Каждая компонента вектора представляет собой значение “серости” (яркости) пикселя во входном изображении. Мы обозначим соответствующее выходное значение как $y = y(x)$, где $y$ это 10-мерный вектор. Например, если входной пример представляет собой “6”, то желаемое выходное значение сети $y(x) = (0, 0, 0, 0, 0, 0, 1, 0, 0, 0)^T$. Отметим, что $T$ означает транспонирование вектора, превращающее вектор-строку в стандартный вектор-столбец.

Теперь наша цель — алгоритм, который позволил бы нам подобрать веса и смещения так, чтобы выходные значения нашей нейронной сети приблизительно совпадали с $y(x)$ при всех входных $x$. Для количественной оценки того, насколько мы приблизились к этому, введём функцию оценки:

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

$$ \begin{eqnarray} C(w,b) \equiv \frac{1}{2n} \sum_x | y(x) - a|^2. \label{6}\end{eqnarray} $$

В этом выражении $w$ обозначает набор всех весов в сети, $b$ — набор всех смещений, $n$ — это общее кол-во тренировочных примеров, $a$ — это вектор всех выходных значений для всех входных значений $x$, а суммирование происходит по всем тренировочным примерам $x$. Конечно же, выход $a$ зависит от $x$, $w$ и $b$, однако, для упрощения выражения я не стал явно записывать эту зависимость. Запись $|v|$ просто обозначает длину вектора $v$. Будем называть $C$ квадратичной функцией оценки, также называемой среднеквадратичным отклонением. Анализируя форму квадратичной функции оценки, заметим, что $C(w,b)$ неотрицательна, поскольку все слагаемые в сумме неотрицательны. Более того, стоимость становится малой, т.е. $C(w,b) \approx 0$, только тогда, когда $y(x)$ приблизительно равно выходным значениям $a$ для всех тренировочных примеров $x$. Выходит, что наш алгоритм хорошо справился со своей задачей, если он смог найти такие веса и смещения, что $C(w,b) \approx 0$.

И наоборот, если $C(w,b)$ велико, мы можем заключить, что алгоритм не слишком преуспел в подборе весов и смещений и для большого числа входных данных $y(x)$ не достаточно близко к тому, что сеть дает на выходе. Итак, цель нашего алгоритма — минимизировать функцию оценки $C(w,b)$, аргументами которой являются веса и смещения. Другими словами, мы стремимся найти такой набор весов и смещений, при которых функция оценки мала настолько, насколько возможно. Делать это мы будем используя алгоритм градиентного спуска.

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

Но даже согласившись с тем, что функция оценки должна быть гладкой, вы можете задаться вопросом: почему же мы остановились именно на квадратичной функции оценки в выражении $\eqref{6}$. Не является ли такой выбор формы функции — выбором ad hoc, который годится только в данном случае. Возможно, мы получим совершенно другой набор весов и смещений, если выберем другую функцию оценки. Это вполне резонный вопрос и позже мы вернемся к вопросу выбора функции оценки и внесем в нее некоторые изменения. Тем не менее, квадратичная функция оценки идеально подходит для построения основ понимания нейронных сетей, поэтому сначала будем работать именно с ней.

Подводя итог, наша цель в обучении нейронной сети — найти веса и смещения, которые минимизируют квадратичную функцию оценки $C(w,b)$. Это вполне корректно сформулированная задача, однако, в данном виде она имеет довольно много отвлекающих деталей — вопрос интерпретации $w$ и $b$ как весов и смещений, маячащая где-то на фоне $\sigma$-функция, выбор архитектуры сети и т.д. На деле мы можем пока что отбросить все эти детали и сосредоточиться исключительно на аспекте минимизации. Так что пока забудем о конкретной форме функции оценки и вообще о связи с нейронными сетями и т.п. Вместо этого представим, что нам просто нужно минимизировать некую функцию нескольких переменных. Для этого мы собираемся использовать технику, известную как градиентный спуск, которую можно применять для подобных задач минимизации. Позже мы задействуем конкретный вид функций, используемых в нейронных сетях.

Хорошо, давайте будем считать, что нам нужно минимизировать некую функцию $C(v)$. Это может быть произвольная вещественная функция нескольких переменных, $v = v_1, v_2, \ldots$. Отметим, что я заменил обозначения $w$ и $b$ как общее $v$ чтобы подчеркнуть, что выбранная ф-я может быть произвольной — не важно, имеет ли она отношение к нейросетям или нет. Чтобы минимизировать $C(v)$ стоит на время представить $C$ как функцию только двух переменных, пусть это будут $v_1$ и $v_2$:

Долина 2-х переменных

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

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

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

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

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

Чтобы вопрос звучал более точно, давайте подумаем над тем, что произойдет, когда мы сдвинем шарик на малое расстояние $\Delta v_1$ в направлении $v_1$ и на малое расстояние $\Delta v_2$ в направлении $v_2$. Математический анализ говорит нам, что $C$ изменится следующим образом: $$ \begin{eqnarray} \Delta C \approx \frac{\partial C}{\partial v_1} \Delta v_1 + \frac{\partial C}{\partial v_2} \Delta v_2. \label{7}\end{eqnarray} $$

Далее мы собираемся так выбрать $\Delta v_1$ и $\Delta v_2$, чтобы $\Delta C$ стал отрицательным — т.е. мы выберем их так, чтобы шарик скатывался вниз. Чтобы выяснить как сделать такой выбор, полезно определить $\Delta v$ как вектор изменений $v$, $\Delta v \equiv (\Delta v_1, \Delta v_2)^T$, где $T$ опять-таки операция транспонирования, переводящая векторы-строки в векторы-столбцы. Также мы введём градиент $C$ — вектор частных производных, $\left(\frac{\partial C}{\partial v_1}, \frac{\partial C}{\partial v_2}\right)^T$. Мы обозначим вектор градиента как $\nabla C$, т.е.: $$ \begin{eqnarray} \nabla C \equiv \left( \frac{\partial C}{\partial v_1}, \frac{\partial C}{\partial v_2} \right)^T. \label{8}\end{eqnarray} $$

Мы уже почти готовы переписать выражение, определяющее $\Delta C$ через $\Delta v$ и градиент $\nabla C$. Но сначала я хотел бы пояснить одну часто ставящую в тупик деталь, связанную с понятием градиента. Встречаясь с обозначением $\nabla C$ в первый раз многие не могут понять, зачем вообще им следует иметь дело с этим значком $\nabla$. Что вообще он означает? В целом, вполне законно считать $\nabla C$ единым математическим объектом — как вектор, который мы определили выше — который просто записывается двумя символами, а не одним. С этой точки зрения, $\nabla$ просто сигнал, который говорит нам: “эй, далее идёт вектор градиента!”. Существуют более развитые подходы, где $\nabla$ может рассматриваться как полноправный математический объект (к примеру, как дифференциальный оператор), но нам не понадобятся такие тонкости.

Опираясь на эти определения, мы можем переписать выражение $\eqref{7}$ для $\Delta C$ как: $$ \begin{eqnarray} \Delta C \approx \nabla C \cdot \Delta v. \label{9}\end{eqnarray} $$

Данное выражение помогает понять, почему $\nabla C$ называется градиентным вектором: $\nabla C$ связывает изменения в $v$ с изменениями в $C$, что в сущности и можно было бы ожидать от объекта с названием “gradient” (gradient англ. — наклон, скат). Ну а самое главное — то, что градиент даёт нам возможность разумно вносить изменения в $v$ так, что $\Delta C$ будет заведомо отрицательным. В частности, предположим что в качестве $\Delta v$ мы выберем $$\begin{eqnarray} \Delta v = -\eta \nabla C, \label{10}\end{eqnarray} $$

Где $\eta$ — некий малый положительный параметр (будем называть его скоростью обучения). Тогда выражение $\eqref{9}$ можно переписать так: $\Delta C \approx -\eta \nabla C \cdot \nabla C = -\eta |\nabla C|^2$. А так как $| \nabla C |^2 \geq 0$, мы можем быть уверены, что $| \nabla C |^2 \geq 0$, т.е. $C$ всегда будет уменьшаться по мере того, как мы изменяем $w$ согласно $\eqref{10}$. (Тут мы, конечно, не должны забывать про приблизительный характер формул $\eqref{9}$). А ведь именно это свойство и было нам нужно! Теперь мы можем положить уравнение $\eqref{10}$ в основу “закона движения” нашего шарика в терминах алгоритма градиентного спуска. Именно, из уравнения $\eqref{10}$ будем рассчитывать значения $\Delta v$, затем сдвигать наш шарик из положения $v$ в направлении $\Delta v$:

$$ \begin{eqnarray} v \rightarrow v' = v -\eta \nabla C. \label{11}\end{eqnarray} $$

После этого применим эту последовательность ещё один раз, тем самым еще немного сдвинув шарик. Повторяя эту процедуру, мы будем добиваться уменьшения $C$ — до тех пор пока не достигнем минимума, при благоприятных обстоятельствах — глобального.

Кратко резюмируя — вот, как работает алгоритм градиентного спуска: мы вычисляем градиент $\nabla C$ и делаем шаг в противоположном направлении, таким образом “спускаясь” по склону нашего ландшафта. Иллюстрировать это можно так:

Скатывание шарика

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

Для корректной работы алгоритма градиентного спуска значение к-та обучения $\eta$ должно быть достаточно мало, чтобы приблизительное равенство $\eqref{9}$ не теряло смысл. Если это не выполняется, то мы можем получить $\Delta > 0$, что, очевидно, не то, что нам нужно! В то же время, мы не хотим, чтобы $\eta$ было слишком мало, поскольку тогда это сделает изменения в $\Delta v$ крохотными, а алгоритм градиентного спуска очень медленным. На практике обычно $\eta$ варьируют так, чтобы уравнение $\eqref{9}$ оставалось достаточно хорошей аппроксимацией, однако алгоритм не был слишком медленным. Мы позже рассмотрим вопрос, как это будет работать.

Я рассматривал градиентный спуск функции всего двух переменных. Однако, в действительности, всё будет работать аналогично и тогда, когда $C$ — функция нескольких переменных. Предположим, что $C$ — это функция m переменных, $v_1,\ldots,v_m$. Тогда изменение $\Delta C$ в $C$ вызванное малым изменением $Delta v = (\Delta v_1, \ldots, \Delta v_m)^T$ запишется так: $$ \begin{eqnarray} \Delta C \approx \nabla C \cdot \Delta v, \label{12}\end{eqnarray} $$ Где градиент $\nabla C$ это вектор: $$ \begin{eqnarray} \nabla C \equiv \left(\frac{\partial C}{\partial v_1}, \ldots, \frac{\partial C}{\partial v_m}\right)^T. \label{13}\end{eqnarray} $$ Как и в случае двух переменных, мы можем выбрать: $$ \begin{eqnarray} \Delta v = -\eta \nabla C, \label{14}\end{eqnarray} $$ И тогда мы можем быть уверены, что наше (приблизительное) выражение $\eqref{12}$ для $\Delta C$ будет отрицательно. Это даёт нам возможность следовать за градиентом к минимуму, даже если $C$ — функция многих переменных, итеративно применяя правило обновления переменных: $$ \begin{eqnarray} v \rightarrow v' = v-\eta \nabla C. \label{15}\end{eqnarray} $$

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

В самом деле, есть даже интуитивное ощущение, что градиентный спуск действительно оптимальный алгоритм для поиска минимума. Давайте предположим, что мы пытаемся сдвинуться на $\Delta v$ в таком направлении чтобы максимально уменьшить $C$. Это эквивалентно минимизации $\Delta C \approx \nabla C \cdot \Delta v$. Мы ограничим величину шага так, что $| \Delta v | = \epsilon$ для некоего малого заданного $\epsilon > 0$. Другими словами, мы хотим смещение с заданным малым шагом, и пытаемся найти такое направление смещения, чтобы уменьшить значение $C$ как можно больше. Можно доказать, что выбор $\Delta v$ который минимизирует $\nabla C \cdot \Delta v$ это $\Delta v = - \eta \nabla C$, где $\eta = \epsilon / |\nabla C|$ определяется ограничением длины вектора $|\Delta v| = \epsilon$. Так что выходит, что градиентный спуск можно рассматривать как способ двигаться малыми шагами в направлении которое сильнее всего уменьшает $C$ в данной точке.

Упражнения

  1. Докажите утверждение последнего параграфа. Подсказка: если вы еще не знакомы с неравенством Коши-Шварца, советуем с ним ознакомиться.
  2. Я рассмотрел градиентный спуск для функции $C$ от двух и более переменных. А что будет если это функция всего одной переменной? Можете ли вы привести геометрическую интерпретацию градиентного спуска в одномерном случае?

Исследователи создали множество вариантов градиентного спуска, включая те, которые ближе к симуляции скатывания реального физического шарика. Эти физические техники имеют некоторые преимущества, однако и крупные недостатки: они требуют вычисления вторых производных, а это может оказаться очень затратно. Чтобы понять почему они могут быть затратными, предположим, что мы хотим вычислить все вторые производные $\partial^2 C/ \partial v_j \partial v_k$. Если переменных $v_j$ миллион, тогда выходит, что нужно вычислить таких вторых частных производных около триллиона (т.е. миллиона в квадрате)! И это будет весьма накладно с вычислительной точки зрения. Учитывая вышесказанное, можно искать различные численные ухищрения чтобы обойти эту проблему или же искать альтернативы градиентному спуску — область активно развивающаяся ныне. Однако в данной книге мы будем использовать градиентный спуск и его вариации как основной подход в обучении нейронных сетей.

Как можно применить градиентный спуск к обучению нейронной сети? Идея состоит в использовании градиентного спуска для нахождения весов $w_k$ и смещений $b_l$ которые минимизируют стоимость в выражении $\eqref{6}$. Чтобы посмотреть как это работает на практике, давайте вспомним правило обновления переменных в градиентном спуске, с весами и смещениями вместо координат $v_j$. Другими словами, наше “позиция” теперь имеет компоненты $w_k$ и $b_l$ и градиентный вектор $\nabla C$ имеет соответствующие компоненты $\partial C / \partial w_k$ и $\partial C / \partial b_l$. Записывая правила обновления градиентного спуска покомпонентно, получим следующее: $$ \begin{eqnarray} w_k & \rightarrow & w_k' = w_k-\eta \frac{\partial C}{\partial w_k} \label{16}\\ b_l & \rightarrow & b_l' = b_l-\eta \frac{\partial C}{\partial b_l}. \label{17}\end{eqnarray} $$ Последовательно применяя данное правило обновления весов и смещений мы можем “скатываться с холма” в надежде найти минимум функции оценки. Другими словами, это правило можно использовать для обучения нейронной сети.

Существует несколько сложностей в применении правила градиентного спуска. Подробней мы рассмотрим их в последующих главах. Сейчас же хотелось бы упомянуть одну из проблем. Чтобы понять в чём она состоит, давайте вернёмся к квадратичной оценки в выражении $\eqref{6}$. Отметим, что данная функция оценки имеет вид $C = \frac{1}{n} \sum_x C_x$, что есть усреднение по стоимостям $C_x \equiv \frac{|y(x)-a|^2}{2}$ для отдельных тренировочных примеров. На практике, для вычисления градиента $\nabla C$ нам необходимо вычислить градиент $\nabla C_x$ отдельно для каждого тренировочного примера x, а затем их среднее $\nabla C = \frac{1}{n} \sum_x \nabla C_x$. К сожалению, в том случае если кол-во тренировочных примеров очень велико, вычисление градиентов может занять много времени и в итоге обучение будет происходить медленно.

Подход, называемый стохастический градиентный спуск может ускорить обучение. Идея состоит в том, чтобы вычислять $\nabla C_x$ для небольшого случайного подмножества тренировочных примеров при вычисления $\nabla C$. Усредняя по этому малому набору мы получим хорошее приближение истинного значения градиента $\nabla C$, что поможет нам значительно ускорить градиентный спуск, а значит и обучение.

Можно считать, что такой градиентный спуск работает случайно отбирая малый набор из $m$ тренировочных примеров. Обозначим эти случайные входные примеры как $X_1, X_2, \ldots, X_m$, и будем называть их мини-набор. Учитывая, что размер этого набора $m$ всё-таки достаточно большой, чтобы среднее $\nabla C_{X_j}$ было грубым приближением среднего по всем $\nabla C_x$, получим: $$ \begin{eqnarray} \frac{\sum_{j=1}^m \nabla C_{X_{j}}}{m} \approx \frac{\sum_x \nabla C_x}{n} = \nabla C, \label{18}\end{eqnarray} $$ Где вторая сумма — по всему набору тренировочных данных. Переставляя стороны выражения, получим: $$ \begin{eqnarray} \nabla C \approx \frac{1}{m} \sum_{j=1}^m \nabla C_{X_{j}}, \label{19}\end{eqnarray} $$ Подтверждая тем самым, что мы можем оценивать общий градиент через градиенты этой случайно выбранной мини-партии.

Чтобы явно связать всё это с обучением нейронных сетей, предположим $w_k$ и $b_l$ обозначают веса и смещения в нашей нейронной сети. Тогда стохастический градиент работает выбирая случайные мини-партии тренировочных примеров и обучается на них: $$ \begin{eqnarray} w_k & \rightarrow & w_k' = w_k-\frac{\eta}{m} \sum_j \frac{\partial C_{X_j}}{\partial w_k} \label{20}\\ b_l & \rightarrow & b_l' = b_l-\frac{\eta}{m} \sum_j \frac{\partial C_{X_j}}{\partial b_l}, \label{21}\end{eqnarray} $$ Где суммы — по всем тренировочным примерам $X_j$ в текущем мини-наборе. Затем выберем другой мини-набор и натренируем сеть на нём. И так далее, до тех пор пока у нас не закончатся тренировочные примеры, что будет означать конец эпохи обучения. В этот же момент начинается новая эпоха обучения. Между прочим, стоит отметить что существуют различные соглашения о том, как масштабировать функцию оценки и обновления весов и смещений, создаваемые мини-пакетами. В уравнении $\eqref{6}$ мы отмасштабировали общую функцию оценки на $\frac{1}{n}$. Некоторые опускают $\frac{1}{n}$, суммируя по ценам(???) отдельных тренировочных примеров вместо усреднения. Это, в частности, удобно, когда общее число примеров неизвестно наперед. Например, такое возможно если тренировочные данные генерируются на лету. И, сходным образом, правила обновления мини-наборов $\eqref{20}$ и $\eqref{21}$ могут иногда записываться без $\frac{1}{m}$ перед суммами. Принципиального значения это не имеет, поскольку эквивалентно перемасштабированию коэффициента обучения $\eta$. Однако, при детальных сравнениях работ различных исследователей возможно на это стоит обратить внимание.

Мы можем представить стохастический градиентный спуск как политический опрос — гораздо проще выбрать небольшой мини-набор чем применять градиентный спуск к полному набору, так же как проще сделать опрос на улице, чем провести сами выборы. К примеру, если у нас тренировочный набор размера n = 60 000, как в наборе MNIST и выбрать размер мини-набора, скажем, m=10, это будет означать что мы можем получить 6000-кратный прирост в скорости оценки градиента! Конечно же, оценка градиента будет не идеальной — будут статистические флуктуации (колебания?) — однако ей и не нужно быть идеалом: нам достаточно того, чтобы двигаться в общем направлении уменьшения $C$, не обязательно абсолютно точном, а значит нам не нужно и абсолютно точное вычисление градиента. На практике, стохастический градиентный спуск — это общепринятая и мощная техника обучения нейронных сетей, и она является основой многих других техник обучения, которые мы разберем в данной книге.

Упражнения

  1. Особый случай — использование в градиентном спуске мини-пакета размером всего 1. Т.е., на данном тренировочном примере $x$ мы обновляем веса и смещения согласно правилам $w_k \rightarrow w_k' = w_k - \eta \partial C_x / \partial w_k$ и $b_l \rightarrow b_l' = b_l - \eta \partial C_x / \partial b_l$. Затем выбираем другой входной пример и обновляем веса и смещения снова. И так далее итеративно. Эта процедура известна как онлайновое или инкрементальное обучение. В онлайновом обучении, нейронная сеть обучается исходя только на одном примере за раз (так же делают и люди). Назовите какой-нибудь недостаток и какое-нибудь преимущество онлайн-обучения по сравнению с стохастическим градиентным спуском с мини-пакетом размером скажем в 20.

Давайте я подытожу данную часть обсуждением одной особенности, зачастую ставящую в тупик новичков в градиентном спуске. В нейронных сетях стоимость $C$ является, конечно же, функцией множества переменных — всех весов и смещений — и потому является в некоем смысле поверхностью в очень многомерном пространстве. Кто-то возможно забеспокоится: “Эй, мне нужно представлять все эти дополнительные измерения, даже четыре измерения я уже не могу представить, а тут их миллионы!”. Неужели есть какая-то особая способность, которая есть не у всех, а только у крутых супер-математиков? Конечно, ответ — нет. Даже самые продвинутые математики не могут визуализировать четыре измерения каким-то особым образом. Вместо этого они используют используют следующий трюк: используют другие способы представления происходящего. Это именно то, что мы использовали выше: мы использовали алгебраическое (а не визуальное) представление $\Delta C$ для того чтобы найти направление уменьшения $C$. Математики, привыкшие иметь дело с многомерными пространствами со временем нарабатывают набор приёмов, один из которых — алгебраическое рассмотрение — мы использовали. Возможно, эти приемы не обладают той простотой, к которой мы привыкли при визуализации трех измерений, однако, как только вы сформируете некоторый набор техник представления в своей голове — всё станет гораздо проще. Я пока что не стану углубляться в детали этих приемов, если вам интересно, то можете почитать это обсуждение. В то время, как некоторые из обсуждаемых техник довольно сложны, основная часть — вполне интуитивны и доступны любому читателю.

Пишем свою сеть для классификации цифр

Отлично, а теперь давайте писать нашу собственную программу для распознавания рукописных цифр, которая будет обучаться посредством алгоритма стохастического градиентного спуска на наборе данных MNIST. Напишем совсем небольшую программу на Python 2.7 — всего в 74 строки! Сначала нам нужно загрузить данные MNIST. Если вы используете git, вы можете загрузить из репозитория весь код, используемый в этой книге, командой git clone https://github.com/mnielsen/neural-networks-and-deep-learning.git

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

Помните, когда я рассказывал о данных MNIST, я упоминал, что они разделены на 60 000 тренировочных и 10 000 тестовых. Это официальное описание MNIST. На деле мы собираемся сделать разбиение немного иначе. Оставим тестовые изображения как есть, но разделим тренировочный набор из 60 000 изображений еще на две части: набор из 50 000 изображений, который будет использоваться для обучения нейронной сети и отдельный набор из 10 000 изображений для валидации. В этой главе мы не будем привлекать валидационный набор, однако, позже мы задействуем его для оптимизации гиперпараметров нейронной сети — таких, в частности, как коэффициент обучения и других, которые никак невозможно выбрать с помощью самого алгоритма обучения. Несмотря на то, что в данных MNIST исторически не выделяли валидационные данные, многие используют такого рода разбиение при обучении нейронных сетей. Впоследствии, при упоминании термина “тренировочные данные MNIST” я буду подразумевать именно набор из 50 000 изображений, а не из 60 000.

Замечание: как ранее было отмечено, набор данных MNIST основывается на двух наборах данных, собранных NIST, Национальным Институтом Стандартов и Технологий США. Чтобы получился MNIST, данные NIST были урезаны и конвертированы в более удобный формат Янном ЛеКуном, Коринной Кортес и Кристофером Буржес. Подробности здесь. В моём репозитории эти данные хранятся в формате, удобном для быстрой загрузки и обработки в Python. Именно в такой форме я получил их из лаборатории машинного обучения LISA Университета Монреаля (отсюда).

Помимо данных MNIST нам понадобится библиотека Numpy, используемая в Python для ускорения решения задач линейной алгебры. Если она у вас еще не установлена, можете скачать здесь.

Перед тем, как приводить весь листинг программы, я поясню ключевые моменты кода нашей нейронной сети. Ядро — класс Network, который и будет представлять нашу нейронную сеть. Вот как мы будем инициализировать экземпляр класса Network:


class Network(object):

    def __init__(self, sizes):
        self.num_layers = len(sizes)
        self.sizes = sizes
        self.biases = [np.random.randn(y, 1) for y in sizes[1:]]
        self.weights = [np.random.randn(y, x) 
                        for x, y in zip(sizes[:-1], sizes[1:])]

В данном коде список sizes хранит в себе число нейронов в соответствующих порядку слоях. Так, например, если мы хотим создать экземпляр класса Network с 2 нейронами в первом слое, 3 нейронами во втором слое и 1 нейроном в последней слое, мы напишем такой код: net = Network([2, 3, 1])

Смещения и веса в объекте класса Network инициализируются случайными числами при помощи функции Numpy np.random.randn, которая генерирует нормально распределенные случайные числа со математическим ожиданием 0 и среднеквадратичным отклонением 1. Такая инициализация определяет начальную точку работы алгоритма стохастического градиентного спуска. В следующих главах мы задействуем более эффективные способы инициализации весов и смещений, однако, сейчас вполне уместно использовать и способ упомянутый выше.

Отметим, что код инициализации в классе Network предполагает первый слой нейронов — входным слоем, и, поэтому, не участвует в инициализации весов и смещений.

Отметим также, что смещения и веса хранятся в списке матриц Numpy. Поэтому, к примеру net.weights[1] это матрица Numpy, хранящая веса соединений второго и третьего слоя нейронов (а не первого и второго, поскольку в Python списки индексируются с 0). Поскольку net.weights[1] довольно громоздкая запись, будем обозначать ее просто как матрица w. В данной матрице $w_{jk}$ — вес соединения между k^{\rm th} нейроном во втором слое и $j^{\rm th}$ нейроном в третьем слое. Такой порядок индексов $j$ и $k$ может показаться странным — разве не логичней поменять их местами? Главное преимущество именно такой индексации состоит в том, что вектор активаций в третьем слое нейронов запишется так: $$ \begin{eqnarray} a' = \sigma(w a + b). \label{22}\end{eqnarray} $$ В этом уравнении довольно много всего, поэтому давайте разберем его по кусочкам. $a$ — это вектор активаций во втором слое нейронов. Чтобы получить $a’$ мы домножим $a$ на матрицу весов $w$ и добавим вектор смещений $b$. Затем мы применим функцию $\sigma$ поэлементно к каждому компоненту вектора $w a +b$. Это называется векторизацией функции $\sigma$. Легко проверить, что уравнение $\eqref{22}$ даст те же результаты как применение более раннего правила из уравнения $\eqref{4}$ при вычислении выхода сигмоидного нейрона.

Упражнения

  1. Выпишите уравнение $\eqref{22}$ в компонентной форме и убедитесь в том, что оно даёт те же результаты что и правило $\eqref{4}$ для вычисления выхода сигмоидного нейрона.

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

def sigmoid(z):
    return 1.0/(1.0+np.exp(-z))

Отметим, что если параметр z функции — вектор или массив Numpy, то Numpy автоматически применяет функцию sigmoid поэлементно, т.е. в векторной форме.

Затем мы добавим метод feedforward в класс Network, который вычисляет выходное значение сети для данного входного значения. Данный метод просто применяет уравнение $\eqref{22}$ к каждому слою:

Замечание: предполагается, что входное значение a имеет тип numpy ndarray и размер (n, 1), т.е. является не вектором размера (n,), где n — количество входов сети, а матрицей. Если вы попробуете использовать вектор размерности (n, ), то получите странные результаты. Хотя на первый взгляд и может показаться, что вектор размерности (n, 1) — более естественный выбор, использование ndarray размерности (n, 1) в частности облегчает задачу модификации сети таким образом, чтобы она могла принимать множество входных примеров за раз, что весьма удобно.

def feedforward(self, a):
    """Вычислить выход сети для заданного входа “a”.""" 
    for b, w in zip(self.biases, self.weights):
        a = sigmoid(np.dot(w, a)+b)
    return a

Конечно, главное, что мы хотим — обучение объектов Network. С этой целью мы добавим в них метод SGD, который производит стохастический градиентный спуск. Код приведён далее. Он немного запутанный в паре мест, но я всё разъясню после листинга.

def SGD(self, training_data, epochs, mini_batch_size, eta,
        test_data=None):
    """Обучение нейронной сети методом 
    стохастического градиентного спуска с 
    использованием мини-пакетов. 
    Параметр training_data - список кортежей
    "(x, y)" - пару значения входов и желаемых выходов.  
    Остальные обязательные параметры: количество эпох 
    обучения, размер мини-пакета, коэффициент 
    обучения и набор данных для тестирования.  
    Если в вызове будет указан параметр test_data, 
    будет производиться проверка на этих данных 
    после  окончания каждой эпохи и текущий результат 
    будет выводиться на экран. 
    Это может полезно, т.к. позволяет отслеживать 
    динамику процесса обучения, однако, существенно 
    замедляет весь процесс."""
    if test_data: n_test = len(test_data)
    n = len(training_data)
    for j in xrange(epochs):
        random.shuffle(training_data)
        mini_batches = [
            training_data[k:k+mini_batch_size]
            for k in xrange(0, n, mini_batch_size)]
        for mini_batch in mini_batches:
            self.update_mini_batch(mini_batch, eta)
        if test_data:
            print "Epoch {0}: {1} / {2}".format(
                j, self.evaluate(test_data), n_test)
        else:
            print "Epoch {0} complete".format(j)

Тренировочные данные — это список кортежей (x, y), представляющих тренировочные данные и соответствующие им выходы сети. Переменная epochs и mini_batch_size означают именно то, что вы предполагаете — количество эпох обучения и размер мини-пакетов. eta — коэффициент обучения, $\eta$. Если установлен необязательный параметр test_data, то после каждой эпохи обучения сеть будет проверяться на точность и промежуточные результаты будут печататься в консоль. Это удобно для отслеживания прогресса обучения, но значительно замедляет процесс.

Код работает следующим образом. Каждая эпоха обучения начинается со случайного перемешивания тренировочных данных, нарезаемых затем на мини-пакеты подходящего размера. Это простейший способ случайного сэмплирования тренировочных данных. Затем для каждого мини-пакета мы применяем один шаг градиентного спуска. Это делается в строке self.update_mini_batch(mini_batch, eta), который обновляет веса и смещения сети согласно одной итерации градиентного спуска, используя тренировочные данные mini_batch. Далее приведён код для метода update_mini_batch:

def update_mini_batch(self, mini_batch, eta):
    """Обновить веса и смещения сети 
    градиентным спуском,
    используя обратное распространение 
    с единственным мини-пакетом.
    “Mini_batch” это список кортежей “(x, y)” 
    и “eta” - к-т обучения."""
    nabla_b = [np.zeros(b.shape) for b in self.biases]
    nabla_w = [np.zeros(w.shape) for w in self.weights]
    for x, y in mini_batch:
        delta_nabla_b, delta_nabla_w = self.backprop(x, y)
        nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
        nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
    self.weights = [w-(eta/len(mini_batch))*nw 
                    for w, nw in zip(self.weights, nabla_w)]
    self.biases = [b-(eta/len(mini_batch))*nb 
                   for b, nb in zip(self.biases, nabla_b)]

Большая часть работы делается в строке

delta_nabla_b, delta_nabla_w = self.backprop(x, y)

Здесь вызывается алгоритм обратного распространения, который является быстрым способом вычисления градиента функции оценки. Поэтому update_mini_batch работает просто вычисляя эти градиенты для каждого тренировочного образца в mini_batch и затем обновляет self.weights и self.biases соответственно.

Я пока не собираюсь приводить код для метода self.backprop. Мы изучим, как работает обратное распространение в следующей главе, и там вы найдёте код этого метода. Пока что, просто считайте, что он ведёт себя заявленным образом, возвращая градиент для оценки, соответствующей тренировочному образцу $x$.

Давайте взглянем на полный код программы, включая строки документации, которые я пропустил выше. Не считая self.backprop все функции и переменные имеют легко интерпретируемые названия — вся сложность аккумулирована в self.SGD и self.update_mini_batch, которые мы уже обсуждали. Метод self.backprop задействует еще несколько дополнительных функций помогающих в вычислении градиента, а именно sigmoid_prime, которая вычисляет производную $\sigma$ функции, и self.cost_derivative, которую я пока не буду пояснять. Вы сможете понять их суть (и возможно даже глубинные детали) просто пробежавшись глазами по коду и документационные строки. Мы разберем их в следующей главе. Отметим, что хотя программа кажется немаленькой, большая часть — это документация, нужная для того, чтобы код было проще понимать. По факту, в программе всего 74 строки, содержащих выполняемый код. Полный код можно увидеть на GitHub.

# -*- coding: utf8 -*-
# Если вы копируете исходный код с данной страницы в 
# текстовый редактор, удостоверьтесь что сохраняете 
# его на диск в кодировке UTF-8!
"""
network.py
~~~~~~~~~~


Модуль, реализующий стохастический градиентный спуск для 
Прямого распространения в сети. Градиенты вычисляются с 
Использованием обратного распространения. Я постарался сделать
код максимально простым и читаемым.
Он не оптимизирован и в нём отсутствуют много дополнительных, но полезных функций. 
"""

#### Библиотеки
# Стандартная библиотека
import random

# Сторонние библиотеки
import numpy as np

class Network(object):

    def __init__(self, sizes):
        """Список “sizes” содержит кол-во нейронов в соотв. слоях сети.
        К примеру, если sizes = [2,3,1] то это трехслойная сеть, где
        первый слой содержит 2 нейрона, второй - 3 нейрона, и третий - 1 нейрон.

        Веса и смещения сети инициализируются случайно, используя
        нормальное (гауссово) распределение со матожиданием 0 и стандартным          
        отклонением 1. Отметим, что подразумевается, что первый слой - входной слой
        И по соглашению мы не будем устанавливать для них смещения, поскольку они
        используются только для вычисления выходов в последующих нейронах."""
        self.num_layers = len(sizes)
        self.sizes = sizes
        self.biases = [np.random.randn(y, 1) for y in sizes[1:]]
        self.weights = [np.random.randn(y, x)
                        for x, y in zip(sizes[:-1], sizes[1:])]

    def feedforward(self, a):
        """Вычислить выход сети для входа “a”."""
        for b, w in zip(self.biases, self.weights):
            a = sigmoid(np.dot(w, a)+b)
        return a

    def SGD(self, training_data, epochs, mini_batch_size, eta,
            test_data=None):
       """Обучение нейронной сети методом стохастического градиентного спуска с использованием мини-пакетов. Параметр training_data - список кортежей
        "(x, y)" - пару значения входов и желаемых выходов.  Остальные обязательные параметры: количество эпох обучения, размер мини-пакета, коэффициент обучения и набор данных для тестирования.  Если в вызове будет указан параметр test_data, будет производиться проверка на этих данных после  окончания каждой эпохи и текущий результат будет выводиться на экран. Это может полезно, т.к. позволяет отслеживать динамику процесса обучения, однако, существенно замедляет весь процесс."""

        if test_data: n_test = len(test_data)
        n = len(training_data)
        for j in xrange(epochs):
            random.shuffle(training_data)
            mini_batches = [
                training_data[k:k+mini_batch_size]
                for k in xrange(0, n, mini_batch_size)]
            for mini_batch in mini_batches:
                self.update_mini_batch(mini_batch, eta)
            if test_data:
                print "Epoch {0}: {1} / {2}".format(
                    j, self.evaluate(test_data), n_test)
            else:
                print "Epoch {0} complete".format(j)

    def update_mini_batch(self, mini_batch, eta):
        """Обновить веса и смещения сети градиентным спуском,
        используя обратное распространение с единственным мини-пакетом.
        “Mini_batch” это список кортежей “(x, y)” и “eta” - к-т обучения."""
        nabla_b = [np.zeros(b.shape) for b in self.biases]
        nabla_w = [np.zeros(w.shape) for w in self.weights]
        for x, y in mini_batch:
            delta_nabla_b, delta_nabla_w = self.backprop(x, y)
            nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
            nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
        self.weights = [w-(eta/len(mini_batch))*nw
                        for w, nw in zip(self.weights, nabla_w)]
        self.biases = [b-(eta/len(mini_batch))*nb
                       for b, nb in zip(self.biases, nabla_b)]

    def backprop(self, x, y):
        """Возвращает кортеж ``(nabla_b, nabla_w)`` который однозначно
определяет  вектор градиента функции оценки  C_x.  ``nabla_b`` и
        ``nabla_w`` -  послойный список numpy-массивов, идентичный по устройству массивам ``self.biases`` and ``self.weights``."""
        nabla_b = [np.zeros(b.shape) for b in self.biases]
        nabla_w = [np.zeros(w.shape) for w in self.weights]
        # feedforward
        activation = x
        activations = [x] # Послойный список активаций каждого нейрона
        zs = [] # Послойный список векторов z (взвешенных сумм)
        for b, w in zip(self.biases, self.weights):
            z = np.dot(w, activation)+b
            zs.append(z)
            activation = sigmoid(z)
            activations.append(activation)
        # backward pass
        delta = self.cost_derivative(activations[-1], y) * \
            sigmoid_prime(zs[-1])
        nabla_b[-1] = delta
        nabla_w[-1] = np.dot(delta, activations[-2].transpose())
        # Отметим, что переменная l в следующем цикле используется
        # несколько иначе, чем в главе 2 данной книги. Здесь l = 1 означает
        # последний слой нейронов, l = 2 второй слой и т.д. Такое изменение нумерации
        # позволяет задействовать такую полезную  возможность Python как 
        # отрицательные индексы в списках.
        for l in xrange(2, self.num_layers):
            z = zs[-l]
            sp = sigmoid_prime(z)
            delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
            nabla_b[-l] = delta
            nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
        return (nabla_b, nabla_w)

    def evaluate(self, test_data):
        """Возвращает кол-во входных примеров, для которых нейронная сеть
        получила корректный результат. Отметим, что подразумевается, что вывод
        нейронной сети - индекс нейрона, получившего наибольшее значение
        активации."""
        test_results = [(np.argmax(self.feedforward(x)), y)
                        for (x, y) in test_data]
        return sum(int(x == y) for (x, y) in test_results)

    def cost_derivative(self, output_activations, y):
        """Вычисляет вектор частных \partial C_x / \partial a для 
        выходных значений активаций нейронов."""
        return (output_activations-y)

#### Дополнительные функции
def sigmoid(z):
    """Сигмоидная функция."""
    return 1.0/(1.0+np.exp(-z))

def sigmoid_prime(z):
    """Производная сигмоидной функции."""
    return sigmoid(z)*(1-sigmoid(z))

Насколько хорошо эта программка распознает рукописные цифры? Что ж, давайте загрузим в неё данные MNIST. Я сделаю это с помощью небольшой вспомогательной утилиты, mnist_loasder.py, описываемой ниже.

>>> import mnist_loader
>>> training_data, validation_data, test_data = \
... mnist_loader.load_data_wrapper()

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

После загрузки данных MNIST, мы создадим сеть с 30 скрытыми нейронами. А сначала нужно импортировать модуль network, описанный выше:

>>> import network
>>> net = network.Network([784, 30, 10])

В заключение, будем использовать стохастический градиентный спуск для обучения по training_data в течение 30 эпох, с размером мини-пакета 10 и коэффициентом обучения $\eta = 3.0$:

>>> net.SGD(training_data, 30, 10, 3.0, test_data=test_data)

Если вы запускаете код, который я привожу, у себя на компьютере, его выполнение займет некоторое время — для среднего компьютера (по состоянию на 2015 год) это будет несколько минут. Вам стоит запустить программу, продолжить чтение и периодически проверять вывод программы. Если вы спешите, то можете уменьшить число эпох обучения или кол-во скрытых нейронов или использовать только часть тренировочных данных. Не забывайте, что реализации нейронных сетей, используемые на практике работают гораздо быстрее: основная цель нашей программы — не претендовать на рекорды производительности, а в первую очередь — иллюстрировать алгоритмы работы нейронных сетей. И, конечно, уже после завершения обучения наша нейронную сеть будет работать очень быстро на любом компьютере. К примеру, полученные в результате обучения веса и смещения мы сможем без труда использовать в портированной на Javascript версии нашей сети, которую можно будет запускать в браузере, или в нативном мобильном приложении. Теперь проведём разбор данных, которые выводит в консоль наша программа: основную ценность имеет количество корректно распознанных изображений. Как видите, после первой же эпохи это число достигает 9 129 (из 10 000 тестовых изображений)

Epoch 0: 9129 / 10000
Epoch 1: 9295 / 10000
Epoch 2: 9348 / 10000
...
Epoch 27: 9528 / 10000
Epoch 28: 9542 / 10000
Epoch 29: 9534 / 10000

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

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

>>> net = network.Network([784, 100, 10])
>>> net.SGD(training_data, 30, 10, 3.0, test_data=test_data)

Как мы и ожидали, это несколько увеличило процент распознанных изображений — до 96.59%. По крайней мере в данном конкретном случае увеличение числа нейронов несколько улучшает результаты работы нейронной сети.

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

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

  • количество эпох обучения,
  • размер мини-пакетов,
  • коэффициент обучения $\eta$.

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

>>> net = network.Network([784, 100, 10])
>>> net.SGD(training_data, 30, 10, 0.001, test_data=test_data)

Результаты в таком случае куда менее впечатляющие:

Epoch 0: 1139 / 10000
Epoch 1: 1136 / 10000
Epoch 2: 1135 / 10000
...
Epoch 27: 2101 / 10000
Epoch 28: 2123 / 10000
Epoch 29: 2142 / 10000

Можно заметить, тем не менее, что постепенно результаты всё же улучшаются. Почему бы тогда не увеличить коэффициент обучения, скажем, до $\eta = 0.01$. Увеличиваем — и видим, результаты стали еще лучше. Наверно, стоит увеличить $\eta$ еще раз. (Если увеличение $\eta$ улучшает результат работы — увеличивайте еще!). После очередного такого изменения мы получим, к примеру, $\eta = 1.0$ (а возможно, доведём и до 3.0), что, в общем, близко к тому, что мы делали при первом запуске. Так что, несмотря на неудачный выбор значений гиперпараметров перед стартом, по мере обучения мы всё же получаем данные, которые позволяют нам внести изменения в значения этих гиперпараметров.

В целом, отладка нейронной сети может быть непростой задачей. Особенно когда начальный выбор гиперпараметров дает результаты похожие на случайный шум. Предположим, что мы тестируем нашу предыдущую удачную архитектуру с 30 скрытыми нейронами, однако с к-том обучения $\eta = 100$:

>>> net = network.Network([784, 30, 10])
>>> net.SGD(training_data, 30, 10, 100.0, test_data=test_data)

В такой ситуации мы явно переборщили с к-том обучения:

Epoch 0: 1009 / 10000
Epoch 1: 1009 / 10000
Epoch 2: 1009 / 10000
Epoch 3: 1009 / 10000
...
Epoch 27: 982 / 10000
Epoch 28: 982 / 10000
Epoch 29: 982 / 10000

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

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

Упражнения

  1. Попробуйте создать сеть всего с двумя слоями — входным и выходным слоем, без скрытого слоя — 784 и 10 нейронов соответственно. Потренируйте сеть используя стохастический градиентный спуск. Какой точности классификации вам удалось добиться?

Ранее, я опустил детали того, как происходит загрузка данных MNIST. Там нет ничего хитрого. Для полноты я привожу код ниже. Структуры данных для хранения данных MNIST описаны в строках документации — ничего особенного, обычные кортежи и списки объектов ndarray из Numpy (считайте их векторами если до этого не имели дело с Numpy):

# -*- coding: utf8 -*-
# Если вы копируете исходный код с данной страницы в 
# текстовый редактор, удостоверьтесь что сохраняете 
# его на диск в кодировке UTF-8!
"""
mnist_loader
~~~~~~~~~~~~

Библиотека для подгрузки данных MNIST.
"""

#### Библиотеки
# Стандартная библиотека
import cPickle
import gzip

# Сторонняя библиотека
import numpy as np

def load_data():
    """Возвращает данные MNIST в виде кортежа из трех элементов:
    массива тренировочных, валидационных и тестовых данных.

    Тренировочные данные в training_data представляет из себя
    кортеж с двумя элементами: первый содержит 50000 тренировочных
    изображений в виде numpy-массива, каждое из которых в свою очередь
    тоже является numpy-массив из 784 элементов, отвечающих за яркость
    пикселей соответствующего изображения MNIST размером 28 * 28.


    Второй элемент в кортеже training_data &mdash; numpy-массив из 50000,
    которые представляют собой значения цифр (0..9), соответствующие
    изображениям из массива изображений под теми же номерами.

    Валидационные и тестовые данные в validation_data и test_data 
    устроена так же, с той лишь разницей, что изображений &mdash; 10,000.


    Это довольно удобный формат, но для использования в работы
    с нейронными сетями полезно несколько модифицировать вид
    данных в training_data. Это и делается в функции-обертке
    load_data_wrapper(). См ниже.
    """
    f = gzip.open('../data/mnist.pkl.gz', 'rb')
    training_data, validation_data, test_data = cPickle.load(f)
    f.close()
    return (training_data, validation_data, test_data)

def load_data_wrapper():
    """Возвращает кортеж из (training_data, validation_data,
    test_data). Основные действия всё еще происходят в load_data,
    но несколько изменяется формат представления данных.

    А именно, training_data &mdash; список из 50 000 кортежей (x, y),
    где x - 784-мерный numpy-массив с входным изображением, 
    y &mdash; a 10-мерный numpy-массив с соответствующей ему цифрой.

    validation_data и test_data &mdash; это списки из 10 000
    кортежей вида (x, y), где x &mdash; 784-мерный numpy-массив с
    входным изображением, а y &mdash; целое число, соответствующее
    цифре, изображенной в x.

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

    tr_d, va_d, te_d = load_data()
    training_inputs = [np.reshape(x, (784, 1)) for x in tr_d[0]]
    training_results = [vectorized_result(y) for y in tr_d[1]]
    training_data = zip(training_inputs, training_results)
    validation_inputs = [np.reshape(x, (784, 1)) for x in va_d[0]]
    validation_data = zip(validation_inputs, va_d[1])
    test_inputs = [np.reshape(x, (784, 1)) for x in te_d[0]]
    test_data = zip(test_inputs, te_d[1])
    return (training_data, validation_data, test_data)

def vectorized_result(j):
    """Возвращает 10-мерный вектор с 1.0 в позиции j и нулями
    в остальных.  Он используется при переводе определенной
    цифры в набор выходов нейронной сети, который мы хотим получить
    при ее разпозновании."""

    e = np.zeros((10, 1))
    e[j] = 1.0
    return e

Как я выше упоминал наша программка дает довольно хорошие результаты. Но что это значит? Хорошие по сравнению с чем? Хорошо бы иметь некий простой (не привлекающий нейронные сети) базовый тест, с которым мы могли бы сравнивать наши результаты. Конечно, самый простейший вариант — просто пытаться случайно угадать цифру. Такой метод будет корректно работать примерно в 10% случаев. Наши результаты гораздо лучше!

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

Двойка и единица

Так мы пришли к идее вычисления средней яркости каждой цифры от 0 до 9. Когда мы классифицируем новое изображение, мы вычисляем насколько яркое изображение, и находим какая из цифр ближе к этому значению. Это простая процедура, которую несложно запрограммировать, потому я не буду приводить здесь код — но он есть в репозитории. Однако это значительное улучшение по сравнению со случайным угадыванием — 2225 верно распознанных из 10000 тестовых изображений, т.е. точность 22.25%.

Несложно найти другие алгоритмы, получающие точность от 20% до 50%. Если еще постараться, то можно добиться точности свыше 50%. Однако для того чтобы добиться еще более высокой точности стоит задействовать зарекомендовавшие себя алгоритмы машинного обучения. Давайте попробуем один из лучших — метод опорных векторов или SVM. Если не знакомы с SVM, не беспокойтесь, мы не будем вникать в детали этого алгоритма. Вместо этого, мы задействуем библиотеку Python под названием scikit-learn, предоставляющую простой интерфейс для языка Python к быстрой библиотеке на языке C под названием LIBSVM.

Если мы запустим SVM классификатор scikit-learn с настройками по-умолчанию, то получим 9435 корректных результатов для 10000 изображений (код доступен здесь). Это огромный прогресс по сравнению с “наивным” классификатором по яркости цифр. В самом деле, это означает, что SVM работает почти также хорошо как нейронная сеть, уступая совсем чуть-чуть. В последующих главах мы разработаем техники, позволяющие добиться результатов, затыкающие SVM за пояс.

Но и это еще не конец истории. Результат в 94% получен при настройках scikit-learn по-умолчанию. У SVM есть набор конфигурируемых параметров и возможно найти такие параметры, которые еще повысят их производительность. Я не буду этим заниматься, вместо этого отошлю вас к этой статье Andreas Mueller если хотите узнать по данной теме больше. Mueller демонстрирует, что при приложении некоторых усилий по оптимизации параметров SVM возможно добиться результатов свыше 98.5%. Другими словами, хорошо настроенная SVM делает ошибку только в одной цифре из 70. Это чертовски хорошо! Может ли нейронная сеть побить этот рекорд?

По правде сказать, может. На текущий момент, хорошо спроектированная нейронная сеть превосходит любой другой подход, включая SVM. Текущий рекорд (2013 год) — корректная классификация 9979 из 10000 изображений. Этого добились Li Wan, Matthew Zeiler, Sixin Zhang, Yann LeCun, и Rob Fergus. Мы рассмотрим большую часть техник, которые они использовали, в остальных главах. Такой результат близок к способностям людей или даже выше. Но с этим можно поспорить — некоторые цифры MNIST крайне тяжело разобрать даже людям:

Нечитаемые цифры

Готов поспорить, что вы согласитесь, что их нелегко классифицировать! С такими изображениями в исходных данных, особенно поразительно, что нейронный сети способны не могут классифицировать только 21 из 10000 тестовых изображений. Зачастую, при программировании нам кажется, что решение сложной задачи наподобие распознавания цифр MNIST требует хитроумного алгоритма. Однако даже нейронные сети из статьи Wan et al, которую мы упомянули выше, задействуют довольно простые алгоритмы, являющиеся вариациями на алгоритм обучения, рассмотренный нами в этой главе. Выходит, что вся сложность задачи усваивается автоматически из тренировочных данных. В некотором смысле, мораль как наших результатов так и результатов из более продвинутых научных статей, что для некоторых задач:

Хитроумный алгоритм ≤ Простой алгоритм обучения + Хорошие тренировочные данные

Погружаемся в глубокое обучение

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

Чтобы поставить вопрос ребром, предположим через пару десятилетий нейронные сети приведут нас к полностью искусственному интеллекту (ИИ). Будем ли понимать как работают такие интеллектуальные сети? Вероятно такие сети будут непрозрачны для нас, мы не будем понимать откуда берутся такие веса и смещения, поскольку они были выучены сетью автоматически. Во времена первых исследований ИИ люди надеялись, что усилия по построению ИИ также помогут нам понять основные принципы интеллекта и, возможно, функционирования мозга. Но вполне вероятно, что в конечном итоге мы не будем понимать ни работу мозга, ни работу ИИ!

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

Кенгуру Умный ребенок Космос

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

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

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

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

Комбинированная архитектура сети

Вполне вероятно, что сами подсети также можно разделить на части. Предположим мы задались вопросом: “Есть ли глаз справа слева?” Этот вопрос можно разделить на следующие компоненты: “Есть ли там бровь?”, “Есть ли ресницы?”, “Есть ли зрачок?” и т.п. Конечно, такие вопросы должны еще включать в себя пространственную информацию — важно находится ли бровь выше чем зрачок, но пока не будем усложнять модель. Сеть отвечающая на вопрос “Есть ли глаз слева сверху?” может быть разложена на компоненты:

Декомпозиция задачи

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

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

Конечно, я ничего не сказал толком о том как проводить это рекурсивное разбиение на подсети. Определенно не будет практично пытаться подобрать веса и смещения в сети. Вместо этого, нам бы хотелось использовать алгоритмы обучения, чтобы сеть автоматически подбирала веса и смещения — а также иерархию концепций — исходя прямо из тренировочных данных. Исследователи в 1980-х и 1990-х пытались использовать стохастический градиентный спуск и обратное распространение для обучения глубоких нейронных сетей. К сожалению, за исключением пары специальных архитектур, у них получалось не очень. Нейросети обучались, однако очень медленно, что делало их практически бесполезными.

Начиная с 2006 года, был разработан набор техник позволяющих задействовать обучение в глубоких нейронных сетях. Эти техники глубокого обучения основаны на всё том же стохастическом градиентном спуске и обратном распространении, но также задействуют и новые идеи. Эти техники позволили обучать гораздо более глубокие (и большие) нейросети — теперь возможно без особого напряжения тренировать сети с 5-10 скрытыми слоями. И, оказалось что такие сети работают гораздо лучше на многих задачах чем обычные неглубокие сети, т.е. Сети с всего одним скрытым слоем. Причина конечно же кроется в том, что глубокие сети способны строить сложные иерархии концепций. Это несколько похоже на то, как распространённые языки программирования используют модульную архитектуру и идеи абстракций для создания сложных компьютерных программ. Сравнивать глубокую сеть с “мелкой” это как сравнивать высокоуровневый язык программирования, обладающий функциями, классами и объектами с языком ассемблера, не предоставляющим таких абстракций. И хотя абстракции в нейронных сетях имеют другую форму нежели в традиционном программировании, они также важны.

Наверх