Сейчас в сети можно встретить огромное количество разной литературы и курсов, которые предлагают разобраться в основах нейросетей, так зачем же нужна ещё одна подобная статья? И почему именно рекуррентные нейросети?
Что касается второго вопроса, то на него я отвечу чуть позже в этом тексте, а сейчас рассмотрим вопрос полезности этой публикации. До недавнего времени я и сам смотрел бы с сомнением на идею написания такой статьи. Изначально мне хотелось просто проверить некоторые свои идеи. Но пока я приводил в порядок все свои знания по этой теме, стараясь добиться полного понимания, я осознал, что большинство источников в основном просто повторяют друг друга. Некоторые вопросы для меня оставались не прояснёнными. Размышляя над различными моментами я подумал, что возможно эти рассуждения смогут принести пользу ещё кому-то. Так возникло желание не просто упорядочить свои мысли, но и оформить их в виде статьи.
Я постарался написать этот текст таким образом, чтобы он достаточно полно описывал математические выкладки, и при этом не дублировал множество других источников на эти темы. Поэтому часть материала я буду приводить без детального разъяснения, ссылаясь на другие публикации, однако на некоторых спорных моментах, по которым у меня возникли вопросы или которые недостаточно полно описаны в литературе, я буду отдельно заострять внимание и поделюсь всеми моими соображениями на эту тему. При этом, все математические преобразования постараюсь приводить достаточно подробно, даже если они и кажутся мне очевидными.
Также сразу оговорюсь, что эта статья ставит целью разобраться именно в основных концепциях рекуррентных нейросетей. Так что здесь не будет описания и сравнения достоинств и недостатков различных популярных, но более сложных архитектур (GRU, LSTM,...). Моя задача --- лишь заполнить те возможные пробелы в базовых знаниях, которые могли остаться у самостоятельно изучающего нейросети по доступным источникам. Имея надёжный фундамент будет проще разобраться и в сложных вещах.
Учитывая всё вышесказанное, могу сказать, что эта статья предназначена скорее для читателей, которые уже знакомы в какой-то степени с рассматриваемой темой, но хотели бы более ясной картины. Если же для читателя всё описанное тут давным давно понятно, но у него есть много других интересных мыслей, которыми очень хочется поделиться или обсудить некоторые моменты, тогда добро пожаловать в комментарии.
Впрочем никакой сложной математики здесь не будет — знаний правил дифференцирования и основ работы с матрицами будет вполне достаточно, так что статья может быть полезна и для первого ознакомления с нейросетями.
Однако, прежде чем переходить собственно к материалу, небольшое...
Я всегда интересовался темой искусственного интеллекта. Ещё будучи школьником я зачитывался книгами про роботов, про технические, моральные и философские проблемы, которые встанут перед человечеством, если загадка сознания когда-нибудь будет решена и станет возможным создание искусственного разума. Сборник "Твоё электронное Я", подаренный мне на одной из олимпиад, оказал существенное влияние на всё моё мировоззрение.
Однако я никогда не мог погрузится в эту тему полностью и с головой. Каждый раз, когда я начинал изучать ту или иную тему, моё внутреннее чувство говорило мне, что всё-таки это не то, чего я хочу... А хотел я собственно того, что сейчас принято называть термином AGI. Я пытался понять смысл разума и вообще высшей психической и нервной деятельности. В своих поисках я забредал в самые разные области человеческого знания. Все ранние достижения в области машинного обучения, обработки текстов, даже какие-то элементы компьютерной лингвистики не только не отвечали моим представлениям о настоящем искусственном интеллекте, но даже не давали намёка в каком направлении стоит проводить исследования. А без ощущения направления мой интерес к теме постепенно затухал.
С появлением больших языковых моделей интерес снова появился. И хотя, после поверхностного ознакомления с их архитектурой, знакомое чувство "это-опять-не-то" вновь всплыло у меня в голове, но некоторые особенности поведения этих нейросетей навели меня на мысль, что "то-самое" уже где-то рядом. Пожалуй довольно забавно, что заинтересовали меня прежде всего особенности, которые вообще-то скорее можно отнести к багам и от которых обычно хотят избавиться.
Первое, что хотелось бы отметить --- т.н. "галлюцинирование нейросети". Это такие ситуации, когда нейросеть очень уверенно выдаёт ответ, который выглядит для стороннего наблюдателя-непрофессионала вполне правдоподобным, но который тем не менее является абсолютно ложным. Эта особенность нейросетей считается едва ли не самым большим их минусом и препятствием к использованию. Однако для меня это наоборот был фактор, который усилил мой интерес. Я вспомнил себя на экзаменах, когда я не знал ответа на вопрос и... я делал ровно то же самое --- галлюцинировал в меру своих возможностей и кстати зачастую удачно :)
Есть и ещё более убедительный на мой взгляд аргумент в пользу тезиса, что галлюцинирование нейросети --- это сигнал приближения к человеческому разуму. Я говорю о явлении, которое называется Парамнезия, или иначе "ложные воспоминания". На хабре есть статья, посвящённая ложным воспоминаниям их разновидностям и причинам их появления. Не буду останавливаться на этом вопросе более детально, для меня сейчас важен тот факт, что эти воспоминания --- это не просто некоторая сознательная ложь. Для говорящего это самая что ни на есть реальная реальность, и он готов её отстаивать. И это может говорить о некоторых глубинных механизмах работы головного мозга, а аналогия, которая наблюдается у искусственных нейросетей, даёт некоторую надежду на возможность моделирования этого процесса так сказать in vitro.
Сейчас появляется всё больше свидетельств, что галлюцинации --- это естественное следствие усложнения системы и появления у неё зачатков сознания (хотел взять в кавычки, но передумал...). В публикации Галлюцинации LLM --- это не баг указывается, что именно рассуждающие модели (reasoning-models) дают гораздо больший процент галлюцинаций. Так что возможно в скором времени компаниям, занимающимся разработкой достаточно сложных ИИ-продуктов потребуются штатные педагоги, психологи, психотерапевты и психиатры. И вовсе не для выгоревших сотрудников. А вот пошутил я сейчас или нет, станет понятно уже в относительно недалёком будущем :)
Следующее, на что могу обратить внимание --- это некоторые особенности генерации видео. Причём ранние попытки пожалуй даже более показательны. В них в кадре постоянно рождаются, эволюционируют и исчезают различные образы. В качестве примера могу привести совершенно замечательные работы режиссёра, хореографа и дизайнера анимации Джеймса Герда (James Gerde) "танцующие скульптуры богов", в частности Афродиты. Замечу, что сама танцующая фигура (т.е. её форма, динамика движения) --- снята на видео, но всё остальное (сцена, антураж, текстура) --- это всё творчество нейросети и именно там можно в полной мере увидеть визуальные отголоски мыслей, рождённых неживой материей.
Это опять-таки напомнило мне мои собственные ощущения и воспоминания, но ещё более ранние. Когда я был дошкольником, у меня в комнате на стене напротив кровати висела одна фотография. В целом ничего особенного, на фотографии была изображена какая-то городская площадь с гуляющими по ней людьми, в центре стояла стела, по краям --- дома. Типичный городской пейзаж. Однажды я проснулся достаточно рано, когда ещё не полностью рассвело, но основные очертания окружающих предметов уже можно было разглядеть. И вот в таких рассветных сумерках я уставился на эту фотографию. Вскоре я обнаружил, что если смотреть на неё непрерывно, стараясь не двигать зрачками, то фигуры на фотографии начинают оживать, двигаться, потом появляются совершенно новые персонажи, и даже пейзажи и окружение могли меняться. Картинки были неясные, почти ускользающие, особенно если пытаться на них сильно концентрироваться, однако для расслабленного наблюдения они воспринимались как вполне определённые. Более того, небольшим мысленным усилием можно было их направлять и даже в некоторой степени режиссировать сюжет. В итоге передо мной разворачивалось целое действо за которым было очень интересно наблюдать. Всё это могло длиться минут десять. Потом, когда света становилось больше, фотография возвращалась к своему обычному состоянию. Я проводил этот эксперимент несколько раз --- так он меня впечатлил. Так вот, когда я увидел видео сгенерированное нейросетями я вспомнил те образы, возникавшие в моём сознании. Динамика видео сгенерированного искусственной нейросетью очень точно соответствовала моим воспоминаниям.
Кстати, в 2025 году видео с танцующей Афродитой было переделано с использованием новых разработок. Да, видео безусловно стало более качественным, но кажется оно потеряло часть того шарма, который придавали ему эти неясные постоянно возникающие и исчезающие формы. Продолжая аналогию с моими воспоминаниями можно сказать, что в ярком дневном свете фотография обрела чёткие очертания потеряв полудремотную утреннюю расслабленность.
Однако, как я уже говорил, несмотря на некоторый всплеск интереса после знакомства с последними достижениями в области искуственно интеллекта изучение нейросетей основанных на архитектуре трансформера не могло меня полностью удовлетворить. И вот недавно вдруг выяснилось, что мои сомнения уже не только мои. Точно так же в этой технологии начали сомневаться и люди, которые не то, что варятся в этом супе всю жизнь, а буквально являются шеф-поварами на той кухне. Так например Илья Суцкевер, канадо-израильский учёный родом из Нижнего Новгорода и по совместительству со-основатель компании Open AI, недавно заявил, что эпоха масштабирования нейросетевых моделей завершилась и пришло время вернуться к исследованиям. Ему вторит Янн ЛеКун, который ещё более категоричен. По его мнению LLM --- это в принципе тупиковая ветвь развития.
Что ж, я не являюсь гуру в области нейросетей и искусственного интеллекта, но тем не менее решил немного покопаться в этой теме. Архитектура трансформеров начала своё триумфальное шествие после выхода в 2017 году статьи "Attention Is All You Need", написанной учёными из Google. И с этой же статьи началось постепенное угасание популярных ранее рекуррентных нейросетей. Это-то меня и задевало с момента начала моего ознакомления с трансформерами. Мне кажется, что рекуррентные нейросети ещё должны себя проявить. Во всяком случае биологические нейросети в нашей голове можно отнести именно к рекуррентным.
Начиная со второй половины прошлого века на восток нашей страны к заливу Петра Великого каждый год по весне съезжалось множество нейрофизиологов изо всех крупных научных центров Советского Союза. Виной тому был вот этот замечательный моллюск:

Это "аплизия" или иначе "морской заяц". Благодаря этому красавцу в 2000 году получил Нобелевскую премию по физиологии и медицине американский нейробиолог австрийского происхождения Эрик Кандель. Причина, по которой аплизии завоевали сердца многих нейробиологов по всему миру, в том, что они максимально удобны для изучения. В своей книге "В поисках памяти" Кандель подробно описывает как именно он пришёл к такому выбору. Нервная система аплизий достаточно проста --- всего 20000 нейронов. Нейроны имеют крупные размеры (некоторые --- до 1 мм в диаметре) и удобны для непосредственного исследования. Клетка практически не повреждается при введении в неё микроэлектродов и сохраняет жизнеспособность ещё 5-10 часов.
Ну что ж, мне для начала экспериментов также нужен подопытный образец, и тоже максимально простой. Этакий аналог апплизий для нейробиологов. И я такого подопытного нашёл --- это совершенно замечательная реализация простейшей рекуррентной нейросети в одном гисте на гитхабе от Андрея Карпаты: min-char-rnn.py. В этой реализации всего около 100 строк кода и при этом она даёт уже довольно интересные результаты из которых можно сделать некоторые предположения и выводы.
Однако, когда я начал разбираться в алгоритме подробнее, я осознал, что несмотря на внешнюю простоту, это не так уж и просто, даже несмотря на то, что какие-то основы теории мной уже были освоены, да и некоторые эксперименты с нейросетями я уже проводил. Но тогда для экспериментов я применял tensorflow, который скрывал большую часть всей внутренней кухни. А программа min-char-rnn принципиально не использует никаких высокоуровневых библиотек. Самое сложное, что там есть --- это numpy для работы с векторами и матрицами. Идеальный подопытный! Кстати "char" в названии программы означает, что текст скармливается в нейросеть символ за символом. Тем самым выводятся за скобки все вопросы связанные с эмбеддингами.
Однако прежде чем переходить к более детальному разбору кода, алгоритма и математики, сделаем ряд замечаний по обозначениям, терминологии и некоторым общим вопросам относительно искусственных нейросетей.
Для обозначения векторов и матриц будем использовать жирный прямой шрифт (x, h, W,...).
Вектора всегда будут представляться в виде столбцов (это соответствует коду min-char-rnn).
Для обозначения транспонированных векторов и матриц будем использовать верхний индекс T, набранный прямым шрифтом (hT, WT).
Непосредственно векторную форму записи выражений будем использовать достаточно редко, обычно в самом конце. В промежуточных преобразованиях и вектора и матрицы будем записывать курсивом с явным указанием индексов как xi, Wij.
Транспонирование матриц при индексной записи производится просто перестановкой индексов местами:
Для индексации элементов векторов и матриц будем использовать i, j, k.
Для индексации элементов временнОго ряда будем использовать t. Однако в силу того, что эта индексация зачастую играет особую роль, то для того что бы этот индекс сразу бросался в глаза примем следующее правило: индекс временнОго ряда t будем ставить вверху и заключать в квадратные скобки: .
Сделаем также пару замечаний об индексной записи векторов и матриц.
Т.к. по соглашению, все вектора --- это столбцы, то фактически их можно представить, как матрицы размером n x 1, т.е. элементы в индексной записи будут иметь вид xi1, где i --- индекс строки (он же индекс элемента), а 1 --- индекс единственного столбца. Такую запись мы использовать явно не будем, но всё равно будем держать её в голове. Что бы понять почему это важно, рассмотрим матричное умножение.
Матричное умножение в индексной записи имеет вид
. Заметим, что т.к. в индексной записи множители являются скалярами, то они очевидно коммутируют и мы можем переставлять их как нам заблагорассудится. Однако при переводе обратно в векторную форму нужно соблюдать аккуратность. Можно использовать следующее мнемоническое правило --- соседние множители как бы "сцепляются индексами", по которым идёт суммирование. При этом порядок индексов результата определяется порядком индексов по которым не идёт суммирование. Например
. Здесь порядок индексов у C именно такой --- ij, но никак не ji.
Посмотрим, как работает это правило. Обозначим или в индексной записи
Пользуясь тем, что Wij и hj --- скаляры переставим их местами:
или, вспоминая матричное представление вектора
. В таком виде вернуться к векторной форме записи нельзя, поскольку элементы не сцепляются индексами, но индексы мы можем переставить с помощью транспонирования:
. В таком виде правая часть уже соответствует матричному произведению, но как видим у результирующего объекта, соответствующего этому произведению индексы будут идти вот в таком порядке: 1i, при том что в левой части порядок индексов другой. Что бы согласовать порядок индексов мы опять же можем воспользоваться операцией транспонирования, причём не важно какую часть будем транспонировать, правую или левую. Пусть будет левая:
. Вот теперь уже можно получить и векторную форму:
. Мы получили известную формулу транспонирования матричного произведения.
Думаю, что все читатели, которые дочитали до этого момента, уже имеют некоторые представления об искуственных нейросетях. С точки зрения математики нейросеть --- это просто функция многих переменных, которые можно разделить на две группы: 1) входные данные и 2) веса. Это разделение для нас принципиально вот по какой причине. При работе с нейросетью можно выделить два режима: 1) режим обучения; и 2) режим использования. Во время режима использования нейросети, её веса --- это по сути константы. Результат работы зависит только от входных данных. Однако в режиме обучения всё как раз наоборот --- мы меняем веса так, что бы добиться от нейросети максимального соответствия идеальному решению на тех же входных данных. Т.е. в этом случае входные данные мы рассматриваем как константы, а веса --- как переменные.
Поскольку нейросеть --- это функция, то её можно продифференцировать. Соответственно если мы рассматриваем режим обучения, а на этом этапе входные данные --- это константы, то производные по входным данным будут равны нулю.
Итак, повторим... в режиме обучения нейросети входные данные --- константа, веса нейросети --- переменные. Зафиксировав входные данные мы хотим изменить веса так, что бы нейросеть стала в некотором смысле "лучше".
Как мы знаем, что бы формализовать понятие "лучше" в смысле работы нейросети, вводят функцию потерь, loss или в используемых далее обозначениях L --- это некоторым образом придуманная скалярная функция от всех данных нейросети, которую мы хотим минимизировать. Если обозначить {xi --- весь набор входных данных, а {wj} --- весь набор весов, то
При таком подходе скалярность функции потерь важна, т.к. минимизировать вектор невозможно, поскольку вектора невозможно сравнивать (если сравнивать их длины, то длина вектора --- это уже скаляр).
Предположим, что мы немного изменили веса нейросети на величину . Тогда новое значение функции ошибки будет
, которое можно разложить в ряд Тейлора с точностью до бесконечно малых первого порядка:
т.е.
где в конце стоит скалярное произведение двух векторов: --- вектор поправок к весам и вектор
(читается набла эль), называемый градиентом скалярной функции L.
Для обучения нейросети мы хотим уменьшить значение функции ошибок L и желательно побыстрее, а значит величина должна быть отрицательна и максимальна по модулю. Скалярное произведение векторов --- это произведение их длинн на косинус угла между ними, значит оно будет отрицательно и максимально по модулю, когда угол между векторами будет
, т.е. когда вектора будут противоположно направлены. Иными словами нам выгодно в качестве
взять вектор, направленный противоположно
:
(здесь
--- просто некоторый числовой параметр, влияющий на стабильность и сходимость алгоритма; как правило имеет достаточно маленькое значение).
Это вкратце суть метода градиентного спуска --- одного из наиболее популярных методов обучения нейросетей (есть и другие методы, но о них как-нибудь в следующий раз). Метод градиентного спуска хорошо известен, много где описан, но я его здесь привёл не только и не столько для полноты картины, сколько из-за того, что хотел бы сделать акцент на терминологии, используемой в литературе в контексте применения этого метода к обучению нейросетей, о чём будет следующий параграф.
Здесь же зафиксируем, что для вычисления поправок к весам нам неплохо бы знать значения частных производных функции потерь по всем весам нейросети или, что то же самое, градиент функнции потерь.
Один из наиболее популярных практических методов применения метода градиентного спуска --- это метод, который я буду называть методом вычисления градиентов в обратном направлении. Тут искушённый читатель закономерно спросит, а почему бы не использовать общепринятое название метод обратного распространения ошибки?... И будет безусловно прав в своём удивлении, т.к. именно таков устоявшийся термин в русскоязычной литературе. Однако я не считаю его удачным... Оригинальный? --- пожалуй да; запоминающийся? --- безусловно; но вот удачный ли он? Тут у меня есть сомнения.
Термин "метод обратного распространения ошибки" говорит нам, что смотрите, вот у нас есть некоторая ошибка, т.е. отклонение результата от желаемого значения. Мы берём эту ошибку и дальше прогоняем по всей нейросети в обратном направлении, некоторым образом изменяя веса нейросети. Такую трактовку можно встретить во множестве мест. Например вот статья на хабре: Фундамент AI: обратное распространение ошибки простыми словами:
Идея обратного распространения заключается в том, чтобы распространять ошибку (потери) в обратном направлении после того, как мы её рассчитали, изменяя веса таким образом, чтобы ошибка немного уменьшилась.
Или википедия: Метод обратного распространения ошибки:
Основная идея этого метода состоит в распространении сигналов ошибки от выходов сети к её входам, в направлении обратном прямому распространению сигналов в обычном режиме работы.
Это только пара примеров из результатов, найденных на первой странице в поиске на Яндексе.
Так что же не так в этом термине?... Чтобы разобраться в этом, давайте возьмём нашего подопытного и посмотрим на его внутренности. Открываем оригинальный код от Андрея Карпаты. Нас интересует функция lossFun --- именно она используется в режиме обучения нейросети и именно в ней вычисляются поправки к весам.

Всю функцию можно разбить на две основные части: строки 36..43 --- вычисление в прямом направлении (прямой проход); и строки 44..58 --- вычисление в обратном направлении (обратный проход), которое по сути и есть то самое обратное распространение ошибки.
В процессе вычисления в прямом направлении вычисляются различные промежуточные значения (подробнее об этом я расскажу дальше в этом тексте), которые сохраняются в массивы для дальнейшего использования. В конце, в строке 43 вычисляется ошибка loss как сумма (по времени) кросс-энтропии между ожидаемым результатом (точным) и фактически полученным.
Казалось бы, ну хорошо, вот мы в процессе прямого прохода вычислили ошибку... отлично, давайте распространять её "обратно" по нейросети. Но если посмотреть на код "обратного распространения ошибки", то мы видим, что эта ошибка в нём вообще никак не используется. Более того, если немного внимательнее посмотреть целиком на весь код, то заметим, что единственная цель вычисления этой ошибки --- это... барабанная дробь... периодически печатать в лог её сглаженные значения! Вот и всё "распространение"...
Так что никакого распространения ошибки не происходит. Рассматриваемый метод --- это просто удобный, наглядный и объективно очень красивый способ вычисления градиентов... но и только.
Термин "обратное распространение ошибки" некорректен и в дальнейшем невольно может приводить к некорректному изложению материала или даже ложным логическим умозаключениям, которые выглядели бы относительно разумно, если отталкиваться только от названия. Простейший пример: допустим мы инициализировали веса нейросети случайным образом и пусть при этом звёзды сложились так, что функция потерь нашей нейросети случайно оказалась в локальном максимуме. Это значит, что вычисленная ошибка будет достаточно велика. Тогда, опираясь на название метода, можно было бы предположить, что поправки к весам будут велики, ведь мы "распространяем" большую ошибку. Но в действительности все поправки будут вообще нулевые! И этот "неожиданный" вывод становится очевидным, как только мы вспомним, что мы не ошибку распространяем, а вычисляем градиенты.
Наверняка для искушённых спецов всё это и не имеет значения, но для новичка, который только погружается в материал может сыграть злую шутку. А возможно и не только для новичка. Взять например книгу Кришнанду Чаудхури "Математика и архитектура глубокого обучения, СПб.: Питер, 2026" (перевод на русский язык ООО «Прогресс книга», 2025). В ней на стр. 340 имеется формула под номером (8.16):
А к этой формуле есть подпись:
Прямое распространение ошибки для произвольного слоя.
Что?... Какое ещё прямое распространение ошибки? Я знаю, что существуют методы автоматического дифференцирования в прямом направлении, например с помощью дуальных чисел, или метод FGD, и я надеялся там почитать именно об этих подходах. Но нет, из формулы да и вообще из общего смысла главы понятно, что речь идёт именно об обычном вычислении на нейросети в прямом направлении. И это явно не единичная опечатка --- там во всём параграфе такое вычисление называется прямым распространением ошибки.
Честно говоря, мне пришлось потратить некоторое время и усилия, что бы осознать все эти тонкости, и что бы пазл сложился окончательно. Не знаю, возникали или возникнут ли подобные вопросы ещё у кого-нибудь, но надеюсь эта моя заметка кому-нибудь принесёт пользу и сделает мир немного понятнее.
К слову, стоит сделать замечание, что в англоязычной литературе используется термин backward propagation или backpropagation, т.е. просто "обратное распространение". И хоть не уточняется, что же именно у них там распространяется, но всё-таки этот термин безусловно гораздо лучше, т.к. не наталкивает на какие-то некорректные выводы. В частности англоязычная википедия даёт следующее определение backpropagation:
In machine learning, backpropagation is a gradient computation method commonly used for training a neural network in computing parameter updates;
Иными словами это именно что метод вычисления градиентов, а не распространение какого-то мифического эфира. Беглый поиск по англоязычной литературе подтвердил, что в этом плане терминология там более корректная. Так например оригинальная версия уже упомянутой выше книги "Математика и архитектура глубокого обучения" использует термины forward propagation и backward propagation, что выглядит вполне приемлемым.
Разумеется не все русскоязычные источники грешат подобного рода неточностями. Здесь я пожалуй не могу пройти мимо главы метод обратного распространения ошибки из учебника по машинному обучению от "Яндекс.Образование". Я дополнительно пересмотрел главу держа в голове то, что изложил выше и должен сказать, что там вся терминология используется вполне корректно. Да, название главы говорит о распространении ошибки, но это как раз нормально. Название должно быть узнаваемым и известным, что бы было проще искать информацию. В самой главе этот термин несколько раз используется, но тоже исключительно как название. Более того, иногда вместо распространения ошибки используют английский термин backward propagation, но часто просто говорят о градиентах и производных.
В общем кажется, что неплохо бы навести порядок в русскоязычной терминологии... Если меня читают люди из "Яндекс.Образование", то может имеет смысл добавить в книгу небольшое разъяснение термина? Просто предложил.
Автор программы, которую я выбрал для экспериментов --- Андрей Карпаты (Andrej Karpathy), учёный родом из Чехословакии. В настоящее время он работает в созданной им Eureka Labs, которая позиционируется как школа нового типа, где делается акцент на широкое применение искусственного интеллекта в образовательном процессе. Около 10 лет назад в 2015 году вошёл в группу учёных, основавших Open AI, а примерно за полгода до этого выложил первую версию простейшей рекуррентной нейросети для обработки текста, которую я и выбрал в качестве лабораторного животного.
Собственно код min-char-rnn Карпаты выложил вместе со своей статьёй Необоснованная эффективность рекуррентных нейронных сетей, в которой можно найти описание довольно интересных результатов экспериментов, но прежде чем переходить к экспериментам, мне сперва хотелось добиться полного понимания математики, заложенной в тот код, который действительно привлекал своей минималистичной красотой.
Скажу сразу, что в тот момент сходу разобраться во всём самостоятельно у меня не вышло. Тогда на просторах интернета я нашёл разбор кода этой нейросетки от Eli Bendersky. Кстати, на хабре есть перевод. Проштудировав статью я понял только то, что всё несколько сложнее, чем я ожидал изначально. Тогда мне попалось ещё одно описание, сделанное Michel Kiffel. Забавно, что в нём он ссылается на текст Eli Bendersky и говорит в свою очередь (даю сразу перевод):
Однако, даже прочитав все комментарии Eli Bendersky, у меня осталось больше вопросов, чем ответов. <...> Мне потребовалось некоторое время и несколько страниц математических выкладок, чтобы по-настоящему понять, что происходит.
Значит не у меня одного возникли затруднения с этим кодом. Статья Kiffel'а несколько прояснила ситуацию, но полного понимания всё равно не было.
Тогда я решил проделать все расчёты самостоятельно и получить те формулы, которые используются в коде. Т.е. именно в том самом виде. Вот эти расчёты я и предлагаю сейчас вашему вниманию. Когда в моих рассуждениях я буду доходить до моментов, которые вызвали у меня затруднения при чтении статей Eli Bendersky и Michel Kiffel, я постараюсь сделать на них акцент, рассказав кратко что именно мне было непонятно и почему.

При разборе кода я буду максимально близко следовать нотации, которую использует Michel Kiffel. Она имеет несколько отличий от той, которая есть в коде. В частности при прямом проходе в коде используются названия переменных {xs,hs,ys,ps}. Для краткости записи формул я опускаю суффикс s, т.е. буду использовать вот такой набор {x,h,y,p}. Суффискс s означает по-видимому 'sequence' в том смысле, что эти переменные представляют собой массивы, которые хранят последовательность соответствующих значений для разных моментов времени. Вообще-то это только моя догадка, которую могу подтвердить тем, что в первой версии кода в обратном проходе тоже были переменные-массивы с аналогичным смыслом, а именно dhs, dys. Но вторым коммитом они из массивов превратились в обычные переменные и потеряли суффикс s.
Также я добавил обозначение z для промежуточного результата прямого прохода нейросети, который получается перед вычисление гиперболического тангенса. Точно такое же обозначение есть у Kiffel'а. В коде это значение вообще никак не фиксируется, но при обратном проходе есть переменная dhraw, которая по смыслу соответствует поправке к этому значению.
Я пропущу описание общей структуры нейросети --- его можно посмотреть например в переводе статьи от Eli Bendersky.
Визуально мы можем представить нашу нейросеть в виде следующей схемы (это несколько модифицированная схема из статьи Eli Bendersky)

Красным цветом на этой схеме отмечены веса и смещения нейросети, т.е. независимые переменные по которым нам надо будет продифференцировать нашу функцию потерь. Зелёным цветом --- промежуточные значения, или зависимые переменные. По ним мы также будем дифференцировать функцию потерь, но только как промежуточный этап вычислений.
Как известно (и о чём можно прочитать например в тех же статьях Bendersky и Kiffel), основная проблема с рекуррентными нейросетями, мешающая применять метод вычисления градиентов в обратном направлении --- это цикличность графа вычислений, которая решается развёртыванием нейросети на несколько итераций. При этом важной деталью является то, что все веса и смещения во всех итерациях строго одинаковы. Метод получил название "Backward Propagation Through Time" или BPTT.
В целом подход понятен и обычно в статьях это просто проговаривают без иллюстраций. Но давайте всё-таки нарисуем картинку для нейросети, развёрнутой на три итерации (в оригинальном коде Андрея Карпаты используется 25 итераций):

Меня здесь интересует прежде всего состояние скрытого слоя нейросети до и после интервала, на котором она развёртывается. Т.е. вот эти самые хвостики, которые на иллюстрации отмечены как и
. Eli Bendersky также обращает на них внимание. Вот что он пишет (здесь и далее привожу цитаты в переводе @honyaki:
<...> Выходное состояние h на последней итерации на самом деле "никуда не пропадает", и мы предполагаем, что у него нулевой градиент. Входное состояние h для первой итерации тоже равно нулю по аналогичным причинам.
Честно говоря, меня такое объяснение не устраивает. Градиент --- это вычисляемая сущность, с чего это мы вдруг предполагаем, что он равен нулю? Точнее предположить-то мы можем, в смысле выдвинуть гипотезу, но потом её надо будет как-то обосновать. Michel Kiffel в своей публикации этот вопрос мягко обходит стороной просто предлагая свой вывод формулы для вычисления градиента, но по сути из этого вывода можно сделать достаточно простое заключение: этих хвостов не существует! Именно так. Разворачивая рекуррентную нейросеть во времени мы на самом деле получаем совершенно другую нейросеть, которая не рекуррентна, т.е. не содержит циклов и пригодна для применения метода вычисления градиентов в обратном направлении. И единственные её входы --- это набор . Других входов нет. Веса, которые мы получим после обучения этой нейросети мы в дальнейшем используем в нашей исходной рекуррентной нейросети и ожидаем, что она при этом заработает именно так, как нам надо.
Итак в режиме обучения наша нейросеть имеет следующий вид:

Следующая фраза, которая несколько меня смутила, вот эта:
<...> Остаётся вопрос о том, как же нам обновить весовые коэффициенты, ведь мы рассчитываем градиенты для них отдельно на каждой итерации. Мы просто суммируем их.
Нууу... интуитивно в общем-то это понятно, но всё-таки хотелось бы более подробного разъяснения откуда именно появляется это суммирование. Так например зависимость каждого от
однозначно и полностью определяется на итерации
, но в то же время зависимость от
определяется не одной итерацией, а целым рядом итераций
. Можно без вычислений предположить, что тут появится некоторое суммирование. А после этого у нас будет ещё и суммирование всех
.
Давайте наконец-то перейдём собственно к разбору всей математики.
Для прямого прохода на итерации t у нас есть входное значение и предыдущее значение скрытого слоя
. Для входных значений используется т.н. one-hot кодирование, которое иногда называют one-of-k. Учитывая, что наша нейросеть работает посимвольно, т.е. за одну итерацию обрабатывает ровно один символ, можно описать суть one-hot кодирования в следующем виде. Предположим, что каждый уникальный символ из нашего текста записан в некоторый массив (назовём его алфавит или словарь) и таким образом имеет числовую характеристику --- индекс символа в словаре. Обозначим этот индекс
. Предположим, что размер словаря N. Тогда one-hot представление символа из алфавита --- это массив из N чисел, все из которых равны нулю за исключением одного, который равен 1. Это элемент с индексом, равным индексу символа.
Такое представление можно рассматривать как распределение вероятности того, что символ соответствует тому или иному элементу словаря. Поскольку вход однозначно определён, то и распределение вероятностей для него имеет вот такой достаточно простой вид.
Думаю написать формулы прямого прохода, опираясь на схему нейросети и код, не составит труда ни для кого. В векторном виде они имеют вид:
Здесь только кратко остановимся на смысле последних трёх операций. А точнее даже двух из них, т.к. с самой последней всё и так ясно.
Функция softmax(...) превращает результат в распределение вероятностей в том же самом смысле, что и рассмотренное ранее распределение вероятностей для входных данных. На самом деле, я тут несколько слукавил. Разумеется функция softmax выдаёт такое распределение вероятностей не сама по себе. Это мы хотим получить такое распределение и для этого выбираем операцию, результат которой в принципе можно проинтерпретировать таким образом. А затем выбрав подходящую функцию потерь и минимизировав её мы автоматически получим, что вот эта операция действительно выдаёт нужное нам распределение вероятности. Собственно главное, что требуется от результата softmax --- это 1) количество элементов результата должно быть равно количеству элементов входа; 2) каждый элемент должен находиться в интервале [0..1]; и 3) сумма всех элементов должна быть равна 1. Можно придумать множество функций, удовлетворяющих этому требованию, но именно softmax оказалась наиболее удобной. В частности она наилучшим образом соответствует следующей операции --- кросс-энтропии, поскольку экспонента в softmax в некотором смысле компенсируется логарифмом в кросс-энтропии.
Кросс-энтропия определяет меру сходства двух распределений вероятности и является естественным обобщением понятия энтропии по Шеннону для одного распределения. Формула для энтропии по Шеннону была по сути получена эмпирическим путём исходя из требований непрерывности и удовлетворения определённым граничным условиям. Кросс-энтропия обладает следующими свойствами: 1) она неотрицательна и 2) минимальна только при точном совпадении распределений. Последнее как раз и сводит нашу задачу к задаче минимизации.
Формула для кросс-энтропии имеет следующий вид:
причём по соглашению, при вычислении энтропии и кросс-энтропии приято следующее правило: .
Поскольку перед нами стоит задача построить нейросеть, способную предсказывать следующий символ, то целевое распределение для вычисления кросс-энтропии на шаге t () --- это распределение, соответствующее следующему символу, т.е.
. В силу того что, как уже было сказано, для входных данных используется кодирование one-hot, то у вектора
все элементы равны 0 кроме одного, который равен 1. Используя принятое ранее обозначение, запишем индекс этого элемента как
.
Тогда, учитывая вышесказанное, можем записать формулу для кросс-энтропии как:
Итак, для обучения нейросети нам требуется минимизировать нашу полную функцию потерь L, настраивая параметры нейросети . Для этого будем использовать метод градиентного спуска, что потребует от нас вычисления производных. Замечу, что можно провести все вычисления сразу в векторном виде используя аппарат матричного дифференцирования, описанный например в учебнике по машинному обучению от Яндекс.Образование, но я здесь не хочу сильно выходить за рамки школьной программы, поэтому проведём все выкладки в индексной записи и только финальный вариант формул будем переводить обратно в векторную запись, учитывая правила, описанные выше.
Перепишем формулы выше явно расписав все операции через индексы.
И теперь, что бы получить формулы для обратного прохода, начнём дифференцировать функцию потерь. Для вычисления частных производных я буду использовать формализм полного дифференциала. Из формулы полного дифференциала функции многих переменных
видно, что частные производные можно получить, как как множители при соответствующих дифференциалах переменных. Также этот формализм позволяет применять цепное правило для дифференцирования сложных функций.
Дифференциал полной функции потерь записывается тривиально:
Далее раскрываем дифференциалы для частей функции потерь, полученных на каждой итерации:
Для вычисления для краткости записи временно опустим индекс t и введём следующие обозначения:
и
. Дифференциалы для этих выражений запишутся в следующем виде:
Тогда представляя pi как , запишем для него дифференциал:
Используя известное свойство символа Кронекера , запишем
, тогда объединяя две операции суммирования в одну, вынося общий множитель pi и группируя члены суммы по дифференциалам dyj получим
или, возвращая индексацию по t
Подставляя это выражение в дифференциал , получим
Таким образом мы можем записать выражение для частной производной по в следующем виде:
Т.е. эта частная производная равна просто выходному вектору у которого один из элементов уменьшен на 1. Красота этой формулы как раз и определяется тем, насколько хорошо подходят друг другу функции softmax и кросс-энтропия.
Если мы посмотрим на строки 49-50 программы
dy = np.copy(ps[t])
dy[targets[t]] -= 1
то увидим, что это как раз и есть выражение для частной производной.
Тут я сделаю небольшой комментарий относительно обозначений. Выражения dWxh, dWhh, dWhy, dbh, dby в коде могут немного сбивать с толку, т.к. это не дифференциалы а условно говоря поправки к соответствующим переменным (с точностью до множителя). Как мы помним из параграфа про метод градиентного спуска, эти поправки (опять же с точностью до множителя) как раз и есть частные производные. Т.е. выражение dWhy в программе --- это именно матрица частных производных . По аналогии с вышеперечисленными, в коде используются и
dy, dh, dhraw, dhnext, которыми также обозначают соответствующие частные производные.
Замечу ещё, что тут мы подошли к моменту, когда зависимости от элементов по которым мы дифференцируем становятся более сложными. Поэтому дальше будем рассматривать дифференцирование поэтапно, начиная с самой первой итерации цикла обратного прохода
, потом
,
и т.д
Ведём символ для обозначения частной производной полной функции ошибок по той или иной переменной, накопленной на текущей итерации цикла
:
и аналогичные ,
,... Эти величины в точности соответствуют переменным
dby, dWxh, dbh,... в коде на каждой итерации цикла.
Двигаемся дальше. Мы рассмотрели дифференциал и получили выражение для дифференциала L через дифференциал y, теперь вычислим сам
. Формула для
достаточно проста и проблем с дифференцированием ни у кого возникнуть не должно, во всяком случае при использовании индексной записи (хотя Eli Bendersky написал аж целую статью на эту тему...). Дифференцируя
получаем:
Подставляем это выражение в формулу для дифференциала функции потерь, имея в виду, что значение для частной производной по мы уже посчитали ранее:
Собирая выражения перед дифференциалами переменных получаем следующие частные производные:
Или, учитывая принятые обозначения для переменных, в которых мы будем накапливать соответствующие частные производные полной функции потерь, запишем:
Переведём сразу эти выражения в векторную запись, учитывая мнемоническое правило, описанное выше:
Эти выражения почти точно соответствуют строкам 51-53 программы:
dWhy += np.dot(dy, hs[t].T)
dby += dy
dh = np.dot(Why.T, dy) + dhnext
за исключением того, что для dh вместо инкремента используется добавление dhnext, но вообще говоря dhnext --- это и есть значение dh на предыдущей итерации цикла, как будет видно далее.
В последнем выражении для дифференциала мы имеем два дифференциала независимых переменных (dWhyji и dbyj) и один дифференциал зависимой переменной (
), который надо раскрыть. Дифференциал выражения для
(т.е. гиперболического тангенса) можно посмотреть по справочнику:
Соответственно частная производная по :
или в векторном виде:
где --- поэлементное умножение. Это выражение соответствует строке 54 программы:
dhraw = (1 - hs[t] * hs[t]) * dh
Далее вычисляем дифференциал , выражение для которого тоже достаточно простое для дифференцирования и нужно только вспомнить, что входные данные
являются константой:
Аналогично всему сделанному ранее, подставляем этот дифференциал в общее выражение для и, собирая выражения перед дифференциалами независимых переменных, получаем следующие частные производные (я буду сразу записывать их в инкрементальном виде для получения полных частных производных от общей функции потерь):
Или в векторном виде:
Эти выражения соответствуют строкам 55-57
dbh += dhraw
dWxh += np.dot(dhraw, xs[t].T)
dWhh += np.dot(dhraw, hs[t-1].T)
и с ними думаю всё очевидно.
Некоторый интерес представляет дифференциал ещё одной зависимой переменной: . Он соответствует состоянию скрытого слоя в момент
и, поэтому должен быть добавлен к соответствующему дифференциалу при дифференцировании
, т.е. на следующей итерации цикла. Иными словами это то самое
dhnext, которое отсутствовало в наших формулах ранее и было равно нулю на первой итерации цикла. Получим выражение для соответствующей частной производной, подставив дифференциал в выражение для
(пишу с сокращениями, опуская все члены, которые уже были рассмотрены):
Выписывая выражение перед получаем значение частной производной, которое надо сохранить в
dhnext:
Или в векторной записи (напомню, что нам надо в правой части получить порядок индексов такой: k1, а значит надо транспонировать, что бы переставить индексы и ещё переставить множители, что бы они "сцепились индексами"):
Это последняя строка цикла backward propagation:
dhnext = np.dot(Whh.T, dhraw)
Выполнив все итерации цикла обратного прохода мы получим в переменных dWxh, dWhh, dWhy, dbh, dby частные производные полной функции потерь по соответствующим весам и смещениям, которые в дальнейшем используются в качестве поправок для изменения этих весов и смещений.
На этом я закончу разбор этой программы. Я постарался пройти все математические выкладки, попутно освещая вопросы, которые возникали у меня в голове в процессе самостоятельного вывода всех формул. Буду признателен, если в комментариях вы поделитесь мнением, насколько был интересен такой формат разбора. Не слишком ли подробно описаны некоторые очевидные вещи, или может наоборот какие-то моменты остались неясными.