Full-mesh Deep Neural Network (DNN) - Полносвязная Глубокая Нейросеть

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

Не берусь утверждать, что нейросети — это «элементарная база», но всё же попытаюсь максимально упростить эту тему; по возможности, не утрачивая информативность.

«Нейрон» — биологическое название нервной клетки; биологическая «нейронная сеть» — нервная система. Искусственная нейронная сеть — это реляционная база данных, в которой коэффициенты реляций — они же «веса связей», между данными в ячейках — они же «узлы» — заведомо неизвестны и генерируются псевдо‑случайным образом — этот процесс называется инициализацией нейросети. Такая нейросеть (инициализированная) со случайными коэффициентами «весов связей» на практике бесполезна и является «необученной». Чтобы «обучить» нейросеть выдавать более‑менее точные и, следовательно, полезные расчёты — необходимо дать ей заведомо точные результаты расчётов и программу «машинного обучения», которую в данном контексте можно считать искусственным интеллектом — хотя на деле, если докопаться до деталей — эта программа не намного «интеллектуальнее» пресловутого программного сортировщика или генератора псевдослучайных чисел.


Программа машинного обучения генерирует случайное число (инициализация) из указанного диапазона (обычно от -0.5 до +0.5) в качестве коэффициента - он же "вес"; затем входные данные умножаются на этот коэффициент, а все такие произведения для каждого отдельно взятого узла - суммируются (прямое распространение). Эта сумма произведений далее "причёсывается" так называемой "функцией активации", чтобы умещаться в конечный диапазон (обычно от 0 до 1) - в качестве такой функции чаще всего используется "сигмоида" - подробнее о ней напишу чуть ниже. Вывод из функции активации сравнивается с требуемым результатом (функция потерь) - в зависимости от результата сравнения корректируется значение "веса" для следующей итерации (обратное распространение) - и эта рекурсия может повторяться до бесконечности, с каждым шагом потенциально сужая диапазон вероятных значений. То, на сколько этот диапазон уменьшился относительно начального диапазона - отражает текущий показатель точности прогнозов нейросети; например, если из диапазона "0 - 1" остался диапазон "0.87 - 0.88", то такая нейросеть достигла уверенности (точности прогнозов) - 99%.

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

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

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


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

Machine Learning (ML) - алгоритм Машинного Обучения

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

Прямое распространение влияет на значения узлов (нейронов).

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

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

Backward Propagation - обратное распространение (ошибки)

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

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

Обратное распространение - это процесс коррекции параметров; его же можно воспринимать как процесс обучения. Начинается с функции потерь и продолжается математическим инструментом "производная", вытекающим в "цепное правило".

Обратное распространение влияет на значения коэффициентов связей (весов).

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

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

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

Эта же формула, но записанная по-другому (я не издеваюсь, вдруг так правда проще?):

где z - сложная функция (прямого распространения) от всех параметров нейросети и функции потерь для выходного узла n; f - функция потерь; σ - функция активации (на примере сигмоиды); k - количество скрытых слоёв нейросети; i - количество узлов в слое; n - номер выходного класса.

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

Но перед, непосредственно, "обратным распространением" - необходима небольшая ремарка об упомянутой выше функции потерь.


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

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

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


Объявим функцию потерь от двух переменных:

Тогда общая функция потерь будет выглядеть так:

где n - количество классов (выходных узлов) в нейросети.

Общая функция потерь на основе среднеквадратичной ошибки:

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

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

К тому же, множитель (2/n) в данном случае, как и ноль, не играет никакой роли - всё равно дальше всё будет умножено на параметр "шага обучения", который задаётся произвольным образом.

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

где i - номер (индекс) выходного узла (класса).

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

Положительную производную функции потерь по прогнозам - можно понимать как количество ошибки; а отрицательная производная функции потерь относительно прогнозов - указывает на степень правильности.

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

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

Цепное правило: производная композиции функций

В переводе на человеческий язык, формула выше означает, что производная сложной (композитной) функции равна произведению производных - внешней функции и внутренней.

Примечание: в обозначениях Лейбница цепное правило для вычисления производной функции z = z(f), где f = f(x), принимает следующий вид:

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

Применяя цепное правило относительно коэффициентов связей - можно вычислить частную производную общей функции потерь по весу связи "w" - градиент по параметру "w"

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

примечание: цифры в квадратных скобках перед выражением - означают его "уровень"; я добавил их для наглядности - далее по тексту будет ясно, зачем они. Математического смысла они не несут, в вычислениях не участвуют!

Рассмотрим по отдельности каждый множитель из этой формулы; начнём с первого - производная общей функции потерь по выводу узла "х".

Поскольку общая функция потерь является суммой всех функций потерь,

то это же верно и для её производной (одно из правил):

Перепишем это же уравнение по отношению к выводу какого-то узла "х":

где n - количество выходных классов; x - индекс взвешиваемого узла.

Каждое слагаемое из этой суммы можно найти по следующей формуле:

где i - целое число от 1 до n, где n - количество выходных узлов нейросети.

При прямом распространении, ввод узла "i" вычисляется простым умножением значения (вывода) узла "x" на коэффициент исходящей из него связи "w"(xi):

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

Производная функции потерь по вводу - как сложная функция, раскладывается в произведение производных:

Ну а здесь уже всё "родное" - первый множитель вспомним из недавних расчётов:

По поводу второго множителя - поскольку вывод с какого-либо узла является функцией активации от его ввода,

то производная вывода по вводу - равняется производной от функции активации по этому вводу, применённой в ходе прямого распространения ко взвешиваемому узлу:

Подставим найденные значения в формулу "уровнем" выше:

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

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

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

где n - количество выходных узлов нейросети; w - параметр, исходящий из взвешиваемого узла.

Вернёмся к "главной" формуле:

  • Первый множитель - только что обозначенная сумма;

  • Производная вывода узла по его вводу - это производная от его функции активации:

  • Производная от ввода по весу - просто равняется предыдущему выводу, из которого идёт связь с искомым весом:

где p - индекс узла из которого идёт связь со взвешиваемым узлом; w(px) - входящая связь во взвешиваемый узел. Смещение (bias) - "голый" вес без коэффициента узла - важный элемент, без которого нейросеть работает менее точно, чем с ним. Ну правда - я не знаю как лучше его объяснить, да и зачем? Иногда лучше просто "shut up and calculate".

Итоговый вид формулы производной общей функции потерь по весу "w" до узла "x":

  • для узла "х" из последнего слоя:

  • для узла "х" из всех остальных слоёв:

где x - индекс взвешиваемого узла; p - индекс "исходящего" узла; n - количество выходных узлов в нейросети; х ∉ p ∉ n

Примечания:

  • если индекс p относится к "узлу смещения" (bias), то переменная "вывод(р)" упраздняется в единицу;

  • w(px) - вес связи от узла "p" до узла "x";

  • w(xi) - вес связи от узла "х" до узла "i".


Итак, нейросеть - это некоторое множество "вводов", на каждый из которых поступают данные из разных источников (подобно нервным окончаниям у людей) и связи различной силы этих "вводов" с "выводами", которые эта нейросеть делает из полученных данных. Что-то ещё?

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

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

Во-вторых - так называемые "нейроны смещения" - узлы с фиксированным значением - добавляются к каждому скрытому слою и не имеют "входных" связей: своеобразные усилители сигнала.

В-третьих - "функция активации" - по сути просто аппроксимация; применяется в тех случаях, где ответ должен укладываться в конечный диапазон. Например - функция активации ReLU

Ну и последнее - "функция потерь" - это первый градиент, с которого начинается обратное распространение.

Cписок всех логических элементов искусственной нейронной сети:

  1. входные узлы (особенности)

  2. коэффициенты связей (параметры)

  3. узлы смещения (bias)

  4. скрытые слои

  5. функция активации

  6. функция потерь

  7. выходные узлы (прогнозы)

Всего-то 7 штук! И все известны уже более 30 лет.

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

Программа состоит из трёх частей: учебный датасет "MNIST" (который скачивается по этой ссылке), состоящий из 60 тысяч картинок цифр с названиями; программный модуль, преобразующий данные из этого датасета и, собственно, код самой нейросети. Сначала - код модуля get-mnist:

# модули numpy и pathlib должны быть установлены в питон
import numpy as np
import pathlib


# Объявление функции "get_mnist", которая будет возвращать значения, указанные ниже в команде return (images и labels).
def get_mnist():

# Из файла извлекается два массива: images (из ключа “x_train”) и labels (из ключа “y_train”). x_train содержит изображения цифр, а y_train - соответствующие им метки (цифры от 0 до 9).
# Примечание: предполагается, что файл mnist.npz размещён в папке data, которая находится в папке со скриптом.
    with np.load(f"{pathlib.Path(__file__).parent.absolute()}/data/mnist.npz") as f:
        images, labels = f["x_train"], f["y_train"]

# Преобразуем тип данных массива images в float32 и сожмём значения в диапазон от 0 до 1 путем деления на 255. 
    images = images.astype("float32") / 255

# images - трёхмерный массив двухмерных картинок, [0] измерение это количество картинок, а измерения [1] и [2] - размерности по высоте и ширине. Умножив размерности [1] и [2] друг на друга - получается общее количество пикселей в изображении. На выходе получается двухмерный массив - матрица.
    images = np.reshape(images, (images.shape[0], images.shape[1] * images.shape[2])) 

# Здесь метки преобразуются в формат "one-hot encoding". Мы создаем матрицу размером 10x10, где каждая строка представляет одну метку (цифру от 0 до 9). Значение 1 в строке соответствует метке, а остальные значения равны 0. 
    labels = np.eye(10)[labels]

# Функция возвращает два массива: images (обработанные изображения) и labels (one-hot encoded метки).
              return images, labels

Далее - разберём код самой нейросети - программы машинного обучения:

# Ниже код программы машинного обучения искусственной нейросети - классификатора изображений
from data import get_mnist
import numpy as np
import matplotlib.pyplot as plt


"""
w = weights (вес), b = bias (смещение), i = input (ввод), h = hidden (скрытый), o = output (вывод), l = label (правильный ответ)
e.g. w_i_h = weights from input layer to hidden layer (вес_ввод_скрытый - вес связи между вводом и скрытым слоем)
"""

# Загрузка данных из датасета
images, labels = get_mnist()

# Инициализация весов случайными числами
w_i_h = np.random.uniform(-0.5, 0.5, (20, 784))
w_h_o = np.random.uniform(-0.5, 0.5, (10, 20))
# Инициализация смещений нулями
b_i_h = np.zeros((20, 1))
b_h_o = np.zeros((10, 1))

# learn_rate - шаг обучения, epochs - количество итераций, nr_correct - отслеживает количество правильных предсказаний
learn_rate = 0.2
nr_correct = 0
epochs = 3

# Цикл обучения
for epoch in range(epochs):

# Чтобы numpy мог посчитать скалярное произведение, необходимо подготовить одномерные матрицы img и l; использование zip ползволяет обрабатывать соответствующие элементы из нескольких массивов одновременно
    for img, l in zip(images, labels):
        img.shape += (1,)
        l.shape += (1,)

	# Прямое распространение ввод -> скрытый слой: вес смещения ввода суммируется с произведениями вводов и их весов (произведения между собой так же суммируются)
        h_pre = b_i_h + w_i_h @ img
	# Применение функции активации "сигмоида":  np.exp - функция экспоненты - возведение числа Эйлера в степень, указанную в скобках. При возведении экспоненты в отрицательные степени - результат стремится к нулю, а при возведении в положительные - к бесконечности
        h = 1 / (1 + np.exp(-h_pre))
        # Прямое распространение скрытый слой -> вывод: вес смещения скрытого слоя суммируется с произведениями значений узлов скрытого слоя и их весов (произведения между собой так же суммируются)
        o_pre = b_h_o + w_h_o @ h
	# Применение функции активации "сигмоида"
        o = 1 / (1 + np.exp(-o_pre))

        # Стоимость ошибок, она же Функция потерь (на примере среднеквадратической ошибки; в данном коде эта переменная никак не используется и оставлена просто для наглядности)

        # e = 1 / len(o) * np.sum((l - o) ** 2, axis=0)

        # Если ячейка с максимальным значением в слое вывода совпадает с ячейкой с максимальным значением в одномерной матрице l - labels - то счётчик правильных ответов увеличивается
        nr_correct += int(np.argmax(o) == np.argmax(l))

        # Обратное распространение вывод -> скрытый слой (производная функции потерь)
        delta_o = (2/len(o)) * (o - l)
        # К весу от скрытого слоя до вывода (на каждый нейрон соответственно) добавляется произведение его правильности на шаг обучения: у ошибочных результатов показатель отрицательный и, следовательно, он вычитается.
        # Транспонирование матриц - необходимо для их умножения. Операция умножения двух матриц выполнима только в том случае, если число столбцов в первом сомножителе равно числу строк во втором; в этом случае говорят, что матрицы согласованы.
        w_h_o += -learn_rate * delta_o @ np.transpose(h) 
        b_h_o += -learn_rate * delta_o

        # Обратное распространение скрытый слой -> ввод (производная композитной функции - производная функции активации умноженная на производную функции потерь))
        delta_h = np.transpose(w_h_o) @ delta_o * (h * (1 - h))
        w_i_h += -learn_rate * delta_h @ np.transpose(img)
        b_i_h += -learn_rate * delta_h

    # Показать точность прогнозов для текущей итерации обучения и сбросить счётчик правильных прогнозов
    print(f"Уверенность: {round((nr_correct / images.shape[0]) * 100, 2)}%")
    nr_correct = 0

# Показать результаты - запрашивает у пользователя номер картинки, которая будет пропущена через нейросеть прямым распространением, в результате которого нейросеть предскажет какая цифра на картинке
while True:
    index = int(input("Введите число (0 - 59999): "))
    img = images[index]
    plt.imshow(img.reshape(28, 28), cmap="Greys")
    
    # Прямое распространение ввод -> скрытый слой
    h_pre = b_i_h + w_i_h @ img.reshape(784, 1)
    # Активация сигмоида
    h = 1 / (1 + np.exp(-h_pre))
    # Прямое распространение скрытый слой -> вывод
    o_pre = b_h_o + w_h_o @ h
    # Активация сигмоида
    o = 1 / (1 + np.exp(-o_pre))

    # argmax возвращает порядковый номер самого большого элемента в массиве
    plt.title(f"Нейросеть считает, что на картинке цифра {o.argmax()}")
    plt.show()

Этот пример, с классификацией цифр датасета MNIST - можно считать "hello world" задачей из мира нейросетей - достаточно наглядный и при этом не слишком сложный код. Если его понимание не вызвало трудностей и интерес к теме сохранился - то можно дополнить список функций активации (начинайте с ReLU - она самая популярная) - wikipedia

Как же нейросети рисуют картинки?

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

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

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

Понятно, что ничего не понятно

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

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

Ну а теперь, "дело за малым" - взять да обратить процесс добавления шума примерно так же, как описанное выше обратное распространение обращает прямое.

Но ведь шум то - случайный! Обратить такое - уже не так просто, как обратить функцию по производной. Любой случайный шум - безвозвратно удаляет часть информации; удаление такого случайного шума либо восстановление информации - задача, по крайней мере до недавних пор, весьма нетривиальная. Для решения такой задачи необходим алгоритм, во-первых, отличающий "шум" от "не шум" и, во-вторых, алгоритм, заменяющий найденный им шум на данные, наиболее подходящие по контексту. Как бы к этой задаче подошёл человек? А давайте проверим - как же хорошо что перед монитором как раз есть такой!

На какой картинке больше шума?

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

Ну пиксели и пиксели.

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

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

Точно такой же принцип используется в "свёрточных нейросетях", наиболее широко известных под термином CNN (convolutional neural network), самым популярным примером которых является архитектура U-Net (потому что её схема похожа на букву U): они смотрят на общий план, выделяют на нём смысловые элементы и сравнивают их с накопленным (в ходе обучения) опытом. То что мы назвали бы "посмотреть на картинку шире", в предыдущем примере, отдаляясь от мелкого фрагмента к полному изображению - в терминологии нейросетей называется "свёрткой" (convolution). В процессе свёртки нейросеть, как бы, абстрагируется от отдельных пикселей к смыслу отдельных элементов - а затем, смыслу всей картинки в целом. Свёртка - это, по сути, сжатие (с потерями), в некотором смысле, напоминающее вычисление хеш-суммы: в процессе свёртки исходные данные сжимаются в сотни и тысячи раз (и больше), при этом - у различных исходных данных свёртки так же будут разные (до определённого уровня). Сопоставлять объекты по их "свёрткам" - намного быстрее, чем сравнивать несжатые объекты. Благодаря этому "лайфхаку" нейросети могут в реальном времени обрабатывать визуальные данные даже на чипах мобильных устройств - современных смартфонов.

Значит, берём нейросеть-классификатор - и дополняем свёрткой, если заведомо её там не было. И тут мы понимаем, что классификатор и свёртка - это одно и то же? По сути - как классификатор превращает сотни вводов в десяток выводов - так же может и свёртка, так в чём разница? Разница в параметрах: у сети-классификатора параметры инициализируются случайным образом, после чего итеративно меняются на шаг обучения и вычисленный с помощью цепного правила градиент; а у свёрточных сетей - все параметры "преднастроены с завода" таким специальным образом, который "одним выстрелом бьёт двух зайцев": и данные сжимает, сохраняя при этом смысловые элементы - и выделяет границы перехода (между смысловыми элементами). Да что уж тут говорить - этот метод настолько "волшебный", что с его помощью можно даже нарисовать тени на плоских (двухмерных) объектах! Задумывались когда-нибудь, как работают эти прикольные фильтры в фотошопе? А вот как:

U-Net: процесс свёртки. Слева - ввод, посередине - фильтр (ядро свёртки), справа - свёртка. Процесс можно повторять и для свёрток, в результате - сжимая исходник насколько угодно.

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

Так выглядит свёртка на конкретном примере (взял из этой статьи):

Слева - оригинал; справа - свёртка. Конкретно это ядро свёртки - увеличивает разницу значений на границах - так называемый "фильтр улучшения чёткости". Матрица этого ядра свёртки - на картинке ниже.
В бесплатной программе GIMP есть наглядный инструмент: фильтры -> общие -> «Матрица свёртки»

Вот ещё примеры самых "базовых" масок 2х2 и 3х3, раздельные по вертикальным и горизонтальным составляющим:

  • Маски Робертса:

Слева - оригинал; справа - сумма "горизонтальной" и "вертикальной" свёрток
  • Маски Собеля:

Маски Собеля дают менее точные грани, чем более "шустрый" аналог Робертса, зато - они менее чувствительны к шуму, благодаря чему на практике более популярны.

Ядра свёртки Робертса и Собеля - используются для выделения граней на изображениях. Каждая маска по отдельности - выделяет только вертикальные, либо горизонтальные грани; на примерах выше изображены суммы этих свёрток - выделяющие все грани в совокупности.

Если нужно именно сжать исходник, а не выделить грани, то есть и более простой алгоритм свёртки - "Max Pooling" - он просто сохраняет максимальное значение из каждой группы, удаляя все остальные; за один "проход" сжимая исходник в 4 и более раз:

Max Pooling - самый популярный способ "пулинга" в нейросетях.

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

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

сжав картинку в 65 раз, max pooling лучше сохранил усы, а average pooling - лучше сохранил глаза

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

другое дело! и усы, и глаза - всё на своих местах, да и разницы практически нет, но max pooling всё же чуточку лучше?

А вот и та самая U-Net, на схеме которой синими стрелочками изображены операции выделения граней свёрткой, а красными - пулинг максимальных значений:

при ядре свёртки 3х3, размерности слоёв свертки всегда различаются на 2 по горизонтали и 2 по вертикали. Если нет - значит используется "костыль", расширяющий матрицы за их границы.

Вернёмся к вопросу распознания и коррекции шума. Если на вход свёрточной нейросети подать картинку с сильными помехами - то свёртка такой нейросети масками, выделяющими границы перехода - лишь усугубит ситуацию: перед такими свёртками, входные данные уместно пропустить через "медианный фильтр" - обычно это маска 3х3 или 5х5, на выходе у которой - самое среднее значение из всех, которые попадают в область маски. Медианный фильтр - эффективное средство подавления случайных аномалий (шума) - но это всё ещё ни в коем смысле не средство восстановления информации.

Слева - оригинал; справа - медианный фильтр 7х7. Помните магнитофоны VHS?
свёртка оригинала и свёртка после медианного фильтра: на последней значительно меньше граней относятся к шуму и больше - к реальным объектам

Так о чём это я? Ах да - нейросети, превращающие текст в картинки. Но какой текст? Где условия? Неужели можно ввести прямо таки что угодно, и нейросеть нарисует?! Лукавят, лукавят эти "дата саентисты"! Что, с руками проблемы? Пальцев многовато? Нет, не туда смотрите. Пальцы, волосы, прочие неестественности - это всё ерунда, которая легко решается нормальным датасетом, архитектурой и обучением. То что нейросети уделяют недостаточно внимания каким-то деталям - как говорится, "дело техники" - пройдёт несколько лет и эти проблемы уйдут в историю. Но есть кое-что, чего "диффузионные" модели не нарисуют - хоть ты тресни! Уже догадываетесь? Вот вам несколько запросов и их результаты - от одной из самых популярных и самых "мощных" среди публично доступных нейросетей - stable diffusion:

"noisy image of mountains"

Я вижу "mountains", но я не вижу "noise". Может быть, я не так смотрю, или не тот запрос? Попробуем другой, чуть более конкретный:

"very bad quality, noisy image, very rubbish and low resolution picture of nature, add noise effect"

И снова неудача! Снова - из всего моего длинного и очень конкретно сформулированного запроса, нейросеть поняла только одно слово "nature".

Не сдаёмся (пока бесплатные генерации ещё есть):

"show two images of same place but one with noise and other without noise to show the difference between noisy image and clear image"

Запрошенной разницы между картинками - лично я не вижу. По моему скромному мнению - количество шума с обоих сторон одинаковое.

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

А дело всё в последнем элементе таких вот генеративных нейросетей - благодаря которому их полное название - это "Генеративно-состязательные сети" (Generative adversarial network, сокращённо GAN): речь о том самом состязательном элементе этих нейросетей, назначение которого - "лупить палкой" по генератору, если его "творение" хоть чем-то не устраивает состязателя. Именно поэтому генеративно-состязательные сети показывают нам "залипательные" картинки, как бы "отполированные" до блеска - но не могут показать нам всё то, что "состязатель" помечает "браком"; а бракует он всё то, что вызывает у него "смешанные" чувства - все результаты, которые вызывают у нейросети недостаточную (достаточность задаётся администратором) активацию по классам, соответствующим текстовому запросу пользователя, или же если активация по "плохим" классам (тоже задаются администратором) слишком высокая - например, если у нейросети-классификатора, выступающего в роли состязателя, глядя на выдачу псевдослучайного генератора возникает слишком высокий вывод по классу "шум" - то такой состязатель попросит генератор изменить выдачу - но не сильно - на небольшой определённый шаг. Генератор, по запросу состязателя, внесёт изменения в данные на входном слое нейросети (который теперь, как бы, в роли вывода, где формируется картинка) - на что состязатель ему скажет - в правильном ли направлении были внесены изменения или нет. Если направление не правильное - оно меняется, если правильное - то дополняется. Вот так - внося небольшие изменения в данные (например, в цвет пикселей), за множество итераций, данные на входном слое нейросети подгоняются под максимальный вывод по запрошенным классам и минимальный вывод по "запрещённым" классам (которые обычно связаны с шумом и низким качеством изображения, цензурным контентом и "счётчиком пальцев" - в качестве примера).

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

из крайности в крайность...

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

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

Источники и ссылки на дополнительные материалы по теме:
Плагин для Экселя "Nerual Excel" - youtube
Матричные фильтры обработки изображений - хабр
Pooling In Convolutional Neural Networks
Pooling Methods in Deep Neural Networks, a Review - PDF
Классификатор изображений на питоне - youtube
Ряды Тейлора и ряды Фурье в нейросетях - youtube
Оптимизация программы обучения - стохастический градиентный спуск - wikipedia
Свёрточные нейронные сети - wikipedia
ну и моя "записная книжка" - в телеграмме

Публично доступные GAN (авторизация аккаунтом гугл):
https://wepik.com/ai-generate
https://stablediffusionweb.com/


P.S. не советую верить всяким лицам нетрадиционной ориентации, особенно - в части их прогнозов на скорейшее пришествие искусственного сверх-интеллекта и прочих "сингулярностей" - на сегодняшний день это просто смехотворно. Сэм Альтман выдавливает все соки из своего "хайпа", со временем всё больше напоминая эдакую Грету Тундберг в мире нейросетей. Туда же - всех нытиков, кто сетует на нейросети "лишающие" их работы.

Есть лишь один способ угомонить этих "недо-эко-активистов" и остальных плоскоземельщиков - игнорировать их и деликатно презирать всех кто ещё не делает этого. От всей души прошу. Просвещения и всех благ дочитавшим.