В этой статье мы

  • Потестируем и немного модифицируем простейшую нейросеть на python‑е реализованную через операции над матрицами из книги Тарика Рашида «Создаём нейронную сеть».

  • Напишем и протестируем несколько вариантов простейшей нейросети на java‑е реализованных через согласованную работу нейронов

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

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

Немного личных откровений, которые спокойно можно пропустить

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

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

Дональд Кнут меня побери, да это не просто магия, а какое-то дикое, тёмное колдунство!

За спиной годы работы в финтехе. Да что там годы - десятилетия!
Курс матанализа и линейной алгебры со второго курса института основательно позабыт.
Но, мой друг - наше время пришло.
Мы идём колдовать.

Источники
  • Замечательная книга Тарика Рашида «Создаём нейронную сеть» в которой он максимально просто и доступно объясняет принципы работы и обучения нейросети.

  • Микроцикл из двух статей «Нейронные сети для начинающих» за авторством Arnis71.
    https://habr.com/ru/articles/312450/
    https://habr.com/ru/articles/313216/
    Или та же самая статья выложенная на другом ресурсе http://datascientist.one/neural-networks-for-beginners/
    (Привожу два вида одной и той же статьи так как в одной её копии плохо читаемы оказались одни формулы, а в другой – другие. Совмещение позволяет работать вполне удобно.)

Нейросеть это совокупность нейронов и связей между ними.

Нейрон - элементарный вычислительный элемент нейросети.

Подробнее о нейроне

Согласно используемой математической модели нейрон состоит из

  • Неупорядоченного множества входных связей.

  • Входного сигнала равного сумме всех сигналов, полученных нейроном по всем входным связям.

  • Функции активации.

  • Выходного сигнала равного результату который даст функция активации если на вход ей подать значение входного сигнала.

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

В принципе это же и есть описание алгоритма работы нейрона.

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

Типы нейронов:

  • Входной нейрон – нейрон входного слоя, куда мы помещаем входные данные. С него начинается процесс прямого распространения сигнала.
    - нет входных связей
    - нет функции активации
    - входное значение равно соответствующему элементу вектора входных данных

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

  • Скрытый нейрон – всякий другой нейрон, принадлежащий к любому слою между входным и выходным слоями.

  • Нейрон смещения – особый тип нейрона. Может быть только один на слой.
    Зачем он нужен?
    -нет входных связей
    -нет функции активации
    -входное значение равно выходному и равно единице ровно.

Замечания по функции активации

Зачем вообще нужна функция активации?
Чтобы принести в модель нелинейность (так как все прочие операции работы нейрона и нейросети в целом сугубо линейны).

Требования к функции активации (почему нельзя взять наугад любую)

  • Нелинейность - чтобы привнести в модель нелиейность.

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

  • Сглаживание сверхбольших и сверхмалых значений - иначе наш выходной сигнал с нейрона рискует "улететь в небеса".

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

У нас есть два подхода к эмулированию работы нейрона и нейросети в целом.

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

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

        # inputs- вектор входных сигналов
        # wih - матрица весов связей между вхдоным и скрытым слоем
        # who - матрица весов связей между скрытым и выходным слоем
  
		# рассчитать входящие сигналы для скрытого слоя
		hidden_inputs = numpy.dot(self.wih, inputs)
		# рассчитать исходящие сигналы для скрытого слоя
		hidden_outputs = self.activation_function(hidden_inputs)
		# рассчитать входящие сигналы для выходного слоя
		final_inputs = numpy.dot(self.who, hidden_outputs)
		# рассчитать исходящие сигналы для выходного слоя
		final_outputs = self.activation_function(final_inputs)

        # а где же сами нейроны? Их нет. Только абстракции.

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

//подробнее см java-neiro/src/main/java/neiro3_virual_process/Neiro.java

class Neiro .
    /**
     * Цикл жизни нейрона. Соединённые вместе прямое и обратное распространение ошибки
     */
    public void process(){...}
Полносвязанные нейросети

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

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

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

Идея: проверить какое количество связей чей вес стремится к нулю имеется в полносвязаной сети после обучения. Что будет если просто «разорвать» такие связи? Насколько ухудшится точность выходного результата? Можно ли будет компенсировать упавшую точность минималым дообучением?

Забегая сильно верёд, найдём ответ с помощью следующего кода

        Net net = Net.load("file_net_v4_e1_0.018027014310724426_0.9402");
        //пробежимся по всем связям и "разобьём те, которые имеют слишком маленький вес" и посмотри как упрощение нейросети повлияет на точность итогового результата
        int contDeletedLink = 0;
        for (Neiro neiro : net.neiros)
            for (Link link : Set.copyOf(neiro.outputs))
                if (Math.abs(link.weight) < 0.01) {
                    contDeletedLink++;
                    link.terminate();
                }
        System.out.println("Link deleted: " + contDeletedLink);
        net.trains(datasTrain, targetsTrain, 0.07F, 0.01F, 1, 0.01F);

        //Link deleted: 1483
        //epoch=0, mse=0.009201660757999096, duration(ms)=140271
        //eff=0.9617

Как видите, мы загрузили ранее обученную и сохранённую нейросеть которая на наборе тестов давала MSE-ошибку 0.01802 и общую эффективность 0.9402 (то есть 94,02% правильных ответов)

Наша нейросеть состоит из 784-ёх входных нейронов (плюс один нейрон смещения), 100 нейронов скрытого слоя (плюс один нейрон смещения) и 10-и нейронов выходного слоя
Итого у нас получается связей (784+1)*100 + (100+1)*10 = 79510 связи

Как показывает наш небольшой эксперимент, из 79510-ой связи мы можем удалить 1483-и связи (1,86%) чей абсолютный вес меньше 0.01 без какой-либо потери точности. Процент правильных ответов по прежнему останется равно 94,02%

Более того, если мы проведём дообучение получившейся чуть более легковесной сети то сможем повысить процент правильных ответов до 0,9617, то есть до 96,17%.

Как вообще весь процесс происходит в целом

Рассматриваем только процесс обучения полносвязанной нейросети с учителем

  1. У нас есть: множество пар векторов вида (вектор данных , вектор ответов). Считаем, что эти вектора уже нормализованы, сведены к значениям от 0 до 1 и что там ещё делают с данными перед тем как начинать скармливать их нейросетям.

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

  3. Создаём нейросеть с количеством входных нейронов равных размерности вектора данных и количеством выходных равным размерности вектора ответов.
    Количество промежуточных слоёв и нейронов в них выбираем по вкусу, то есть практически наугад. Также наугад выбираем гиперпараметры сети, такие как коэффициент обучения, момент обучения, количество эпох обучения и так далее. Веса всех связей инициализируем маленькими случайными значениями, например от -1 до +1.

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

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

Алгоритмы

Алгоритмы с точки зрения нейросети
(Чуть дальше от реальности и чуть ближе к абстрактной модели.)

Процесс прямого распространения сигнала
(Условие для начала: входное значение каждого нейрона равно нулю)

  1. Поместить вектор данных в упорядоченную последовательность нейронов входного слоя

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

  3. Забрать вектор ответа с упорядоченной последовательности нейронов выходного слоя.

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

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

  1. Поместить вектор правильных ответов в упорядоченную последовательность нейронов выходного слоя.

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

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

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

Процесс прямого распространения сигнала

(событие: был получен сигнал по одной из входных связей) =>

  • Нейрон вычисляет: входное значение = входное значение (какое было)  + полученный сигнал

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

(событие: у нейрона нет ни одной входной связи, по которой он не получил бы сигнал) =>

  • нейрон вычисляет: выходной_сигнал = функция_активации(входной_сигнал)

  • нейрон вычисляет: входной_сигнал = 0

  • нейрон отправляет выходной_сигнал по всем своим выходным связям. Процесс прямого распространения сигнала для данного нейрона закончен
    Если это выходной нейрон, то производится вызов события выходного слоя с информацией о том что один из его нейронов закончил прямой процесс. Как только его закончат все нейроны выходного слоя – можно будет получать с них вектор выходных данных

Процесс обратного распространения ошибки и коррекция весов связей

(событие запускается для нейронов для которых нет таких выходных связей веса которых мы ещё не откорректировали) =>

  • Для выходных и скрытых нейронов вычисляется дельта ошибки в зависимости от типа нейрона.

  • Для входных, скрытых и биасовских нейронов производится коррекция весов всех исходящих из них связей

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

Элементы вычислительного алгоритма

Дельта ошибки вычисляется так:

  • Если это выходной нейрон, то мы знаем его «идеальный выходной сигнал» из вектора ответов и сможем вычислить
    дельта_ошибки_на_выходном_нейроне = производная_функции_активации(выходной_сигнал) * (идельный_выходной_сигнал – выходной_сигнал)

  • Если это скрытый нейрон, то для него мы не знаем «идеальный выходной сигнал» и поэтому должны вычислять
    Дельта_ошибки_на_скрытом_нейроне = производная_функции_активации (выходной_сигнал_данного_нейрона) * сумму-по-всем-выходным-связям-данного-нейрона(вес-выходной-связи * дельта_ошибки_нейрона_к_которому_ведёт_эта_связь)

Корректировка весов связей считается так

Для исходящей связи из нейрона А к нейрону В, подсчитаем

Градиент(от А к В) = выходное_значение_А * дельту_ошибки_В

Изменение_веса_связи_от_А_к_В = коэфф_обучения_сети * градиент_А_В + момент_обучения * прошлое_изменение_связи_от_А_к_В

И, наконец, новое значение связи А -В = старое значение связи + изменение веса связи.

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

Зная ошибку на нейронах выходного слоя то как нам откорректировать веса входных для этих нейронов связей чтобы уменьшить данную ошибку?

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

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

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

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

Теперь даже гуманитарию понятно, как считается ошибка на выходных нейронах, ведь у нас есть вектор правильных ответов! Но что насчёт ошибки на скрытых нейронах? Ответов для них у нас нет…

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

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

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

Производная функции есть скорость изменения функции.

Градиент функции- направление её изменения. А нам именно направление и надо чтобы просто «идти в нужную сторону». Ещё древние римляне говорили «делай что должно и будет что будет». Вот, они знали толк в нейросетях.
Будь как древний римлянин.

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

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

Пришло время практики.
Для классической задачи распознавания рукописного ввода цифр MNIST

Работающие примеры можно найти в https://github.com/upswet/NeiroFirst

Возьмём нейросеть из книги Тарика Рашида на питоне реализованную через вычисление матриц

# из файла python-neiro/neiro1.py
# в файле python-neiro/neiro2.py приведён пример дораобтанной многослойной нейросети

class NeuralNetwork:
	# инициализировать нейронную сеть
	def __init__(self, inputnodes, hiddennodes, outputnodes, learningrate):
		# задать количество узлов во входном, скрытом и выходном слое
		self.inodes = inputnodes
		self.hnodes = hiddennodes
		self.onodes = outputnodes
		# коэффициент обучения
		self.lr = learningrate

		# использование сигмоиды в качестве функции активации. expit() это как раз сигмоида и есть
		self.activation_function = lambda x: scipy.special.expit(x)

		# Матрицы весовых коэффициентов связей wih (между входным и скрытым
		# слоями) и who (между скрытым и выходным слоями).
		#self.wih = (numpy.random.rand(self.hnodes, self.inodes) - 0.5)
		#self.who = (numpy.random.rand(self.onodes, self.hnodes) - 0.5)
		self.wih = numpy.random.normal(0.0, pow(self.hnodes, -0.5), (self.hnodes, self.inodes))
		self.who = numpy.random.normal(0.0, pow(self.onodes, -0.5), (self.onodes, self.hnodes))
		pass

	# тренировка нейронной сети
	def train(self, inputs_list, targets_list):
		# преобразовать список входных значений в двухмерный массив
		inputs = numpy.array(inputs_list, ndmin=2).T
		targets = numpy.array(targets_list, ndmin=2).T
		# рассчитать входящие сигналы для скрытого слоя
		hidden_inputs = numpy.dot(self.wih, inputs)
		# рассчитать исходящие сигналы для скрытого слоя
		hidden_outputs = self.activation_function(hidden_inputs)
		# рассчитать входящие сигналы для выходного слоя
		final_inputs = numpy.dot(self.who, hidden_outputs)
		# рассчитать исходящие сигналы для выходного слоя
		final_outputs = self.activation_function(final_inputs)

		#ошибка = целевое значение - фактическое значение
		output_errors = targets - final_outputs
		# ошибки скрытого слоя - это ошибки output_errors,
		# распределенные пропорционально весовым коэффициентам связей
		# и рекомбинированные на скрытых узлах
		hidden_errors = numpy.dot(self.who.T, output_errors)
		# обновить весовые коэффициенты связей между скрытым и выходным слоями
        # обратите внимание, что final_outputs * (1.0 - final_outputs)) вот это и есть производная сигмоиды
		self.who += self.lr * numpy.dot((output_errors * final_outputs * (1.0 - final_outputs)), numpy.transpose(hidden_outputs))
		# обновить весовые коэффициенты для связей между входным и скрытым слоями
		self.wih += self.lr * numpy.dot((hidden_errors *  hidden_outputs * (1.0 - hidden_outputs)), numpy.transpose(inputs))

	def trainWithEpoch(self, inputs, targets, epochs:int):
		for е in range(epochs):
			for i in range(len(targets)):
				n.train(inputs[i], targets[i])

	# опрос нейронной сети
	def query(self, inputs_list):
		#преобразовать список входных значений в двухмерный массив
		inputs = numpy.array(inputs_list, ndmin=2).T
		# рассчитать входящие сигналы для скрытого слоя
		hidden_inputs = numpy.dot(self.wih, inputs)
		# рассчитать исходящие сигналы для скрытого слоя
		hidden_outputs = self.activation_function(hidden_inputs)
		# рассчитать входящие сигналы для выходного слоя
		final_inputs = numpy.dot(self.who, hidden_outputs)
		# рассчитать исходящие сигналы для выходного слоя
		final_outputs = self.activation_function(final_inputs)
		return final_outputs


# вызывается оно примерно так
n = NeuralNetwork(input_nodes, hiddenNodesValue, output_nodes, learningRateValue)
# тренировка нейроной сети
n.trainWithEpoch(inputsTrain, targetsTrain, epochValue)
#и использование уже натренированной
output = n.query(inputsTest[i])

Мы можем обучать сеть в двух режимах:
На полном объёме данных (порядка 60 тыс примеров). Для этого раскомментируйте в файле neiro1.py строчки
training_data_file = open("dataset/mnist_train.csv", 'r')
test_data_file = open("dataset/mnist_test.csv", 'r')

Или на ограниченном объёме из 100 обучающих и 10-и тестовых примерах
training_data_file = open("dataset/mnist_train_100.csv", 'r')
test_data_file = open("dataset/mnist_test_10.csv", 'r')

Также управляя параметрами

#преднастроенные (неизменные) параметры сети
input_nodes = 784  #кол-во входных узлов сети
output_nodes = 10 #кол-во выходных узлов сети

#пределы изменения изменяемых параметров сети
#переменный параметр: кол-в скрытых узов сети
hidden_nodes=[40, 60, 80, 100, 150]
#переменный параметр: коэф обучения
learning_rate=[0.2]
#переменный параметр - количество эпох (циклов обучения)
epoch=[1]

вы можете собрать любопытную статистику, например

Зависимость длительности и эффективности обучения от количества нейронов в скрытом слое и коэффициента скорости обучения

Для количества нейронов в скрытом слое
hidden_nodes=[50, 100]
И разных параметров коэффициента скорости обучения
learning_rate=[0.1, 0.3, 0.7]
И количестве эпох обучения всегда равных
epoch=[1]

Получаем

epochCount = 1, hiddenNeironCount=50, learningRate=0.1:
	The task took 3.56 seconds to complete. Effective=0.9422
epochCount = 1, hiddenNeironCount=50, learningRate=0.3:
	The task took 3.69 seconds to complete. Effective=0.916
epochCount = 1, hiddenNeironCount=50, learningRate=0.7:
	The task took 3.60 seconds to complete. Effective=0.8469
epochCount = 1, hiddenNeironCount=100, learningRate=0.1:
	The task took 6.51 seconds to complete. Effective=0.95
epochCount = 1, hiddenNeironCount=100, learningRate=0.3:
	The task took 7.03 seconds to complete. Effective=0.9439
epochCount = 1, hiddenNeironCount=100, learningRate=0.7:
	The task took 7.24 seconds to complete. Effective=0.8775

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

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

У меня нет объяснения почему при 100 нейронах в выходном слое при увеличении коэффициента скорости также увеличивается время обучения. По идее оно должно было оставаться примерно неизменным, как это происходит при 50-и нейронах во входном слое. Видимо что-то связанное с железом.

Зависимость длительности и эффективности обучения от количества нейронов в скрытом слое
#переменный параметр: кол-в скрытых узов сети
hidden_nodes=[10, 20, 50, 100]
#переменный параметр: коэф обучения
learning_rate=[0.1, 0.3]
#переменный параметр - количество эпох (циклов обучения)
epoch=[1]

epochCount = 1, hiddenNeironCount=10, learningRate=0.1:
	The task took 1.74 seconds to complete. Effective=0.8654
epochCount = 1, hiddenNeironCount=10, learningRate=0.3:
	The task took 1.75 seconds to complete. Effective=0.853
epochCount = 1, hiddenNeironCount=20, learningRate=0.1:
	The task took 2.21 seconds to complete. Effective=0.9158
epochCount = 1, hiddenNeironCount=20, learningRate=0.3:
	The task took 2.14 seconds to complete. Effective=0.8826
epochCount = 1, hiddenNeironCount=50, learningRate=0.1:
	The task took 3.80 seconds to complete. Effective=0.941
epochCount = 1, hiddenNeironCount=50, learningRate=0.3:
	The task took 3.79 seconds to complete. Effective=0.9248
epochCount = 1, hiddenNeironCount=100, learningRate=0.1:
	The task took 7.51 seconds to complete. Effective=0.9503
epochCount = 1, hiddenNeironCount=100, learningRate=0.3:
	The task took 7.18 seconds to complete. Effective=0.9417

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

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

Длительность обучения здесь изменятся гладко и объяснимо. Увеличение числа нейронов в скрытом слое в 10 увеличивает длительность обучения примерно в 7,5 / 1.74 = 4, 3 раза

Зависимость качества обучения от количества эпох обучения
hidden_nodes=[10, 100]
#переменный параметр: коэф обучения
learning_rate=[0.1]
#переменный параметр - количество эпох (циклов обучения)
epoch=[1, 2, 4]

epochCount = 1, hiddenNeironCount=10, learningRate=0.1:
	The task took 1.50 seconds to complete. Effective=0.8633
epochCount = 1, hiddenNeironCount=100, learningRate=0.1:
	The task took 6.65 seconds to complete. Effective=0.9499
epochCount = 2, hiddenNeironCount=10, learningRate=0.1:
	The task took 2.90 seconds to complete. Effective=0.8897
epochCount = 2, hiddenNeironCount=100, learningRate=0.1:
	The task took 13.08 seconds to complete. Effective=0.9594
epochCount = 4, hiddenNeironCount=10, learningRate=0.1:
	The task took 5.74 seconds to complete. Effective=0.887
epochCount = 4, hiddenNeironCount=100, learningRate=0.1:
	The task took 26.44 seconds to complete. Effective=0.9669

Видим, что при увеличении количества эпох обучения эффективность обучения для сети с 10-ью нейронами скрытого слоя возрастает с 0.8633 до 0.887 - то есть совершенно незначительно.
Тогда как для сети со 100-а нейронами в скрытом слое эффективность возрастает с 0.9499 до 0.9669 - то есть очень немного

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

Просто для сравнения. Аналогичная статистика собранная всего на 100 примерах для обучения (против 60 тыс ранее)

Как видите, здесь увеличение количества эпох обучения позволило нам поднять эффективность обучения сети с 10-ью нейронами с жалких 0,2 до уже что-то значащих 0,5

Зависимость качества обучения от количества скрытых слоёв и количества нейронов в них

из файла neiro2.py

#пределы изменения изменяемых параметров сети
#переменный параметр: кол-в скрытых узов сети
hidden_nodes=[30, 100]
#переменный параметр: кол-во скрытых слоёы
hidden_count=[1, 3]
#переменный параметр - количество эпох (циклов обучения)
epoch=[1]

epochCount = 1, hiddenLayerCount=1, hiddenNeironCount=30:
	The task took 3.49 seconds to complete. Effective=0.9193
epochCount = 1, hiddenLayerCount=1, hiddenNeironCount=100:
	The task took 7.64 seconds to complete. Effective=0.9455
epochCount = 1, hiddenLayerCount=3, hiddenNeironCount=30:
	The task took 5.06 seconds to complete. Effective=0.6321
epochCount = 1, hiddenLayerCount=3, hiddenNeironCount=100:
	The task took 10.56 seconds to complete. Effective=0.4327

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

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

Рассмотрим java-neiro/src/main/java/neiro/Base.java где нейросеть сама производит вычисления над нейронами в определённом порядке, а сами нейроны используются в основном как место для хранения списка их связей, а также входных и выходных значений

/**Описывает одиночный нейрон*/
public class Neiro implements Serializable {
    TypeEnum typeEnum; //тип нейрона
    FunEnum fun= FunEnum.NONE; //тип функции активации
    List<Link> inputs = new ArrayList<>(); //входные связи
    List<Link> outputs = new ArrayList<>(); //выходные связи

    float valueInput; //входящий сигнал - сумма сигналов полученным по всем входящим связям
    float valueOutput; //исходящий сигнал - результат применения функции активации к входящему сигналу
    float delta; //делтьа. Участвует в корректировке весов в алгоритме обратного распространения ошибки

  ....
}



/**Описывает нейросеть как последовательность слоёв нейронов где каждый нейрон слоя N связан с каждым нейроном слоя N+1*/
public class Net implements Serializable {
    Layer inputLayer; //входной слой
    Layer outputLayer;//выходной слой
    List<Neiro> biasNeiro=new ArrayList<>(); //множество нейронов смещения. Может быть пусто
    List<Layer> layers; //последоватлеьность слоёв


      /**Обратное респространение ошибки от нейронов выходного слоя к нейронам входного слоя с коррекцией весов связей участвующих в процессе
     * @param target - правильный выход нейронов выходного слоя. Обучение с учителем
     * @param lr - коэффициент обучения
     * @param moment -момент*/
    private void backward(float[] target, float lr, float moment) {
        //считаем дельту на нейронах выходного слоя
        int i = 0;
        for (Neiro neiro : outputLayer.neiros) {
            float err = target[i++] - neiro.getValue();
            neiro.delta = err * neiro.derivative(neiro.valueOutput);
        }

        //считаем дельту для нейронов скрытых слоёв сзади наперёд пока скрытые слои не кончатся
        for(int j=layers.size()-2; j>=1; j--){
            Layer layer = layers.get(j);
            for (Neiro neiro : layer.neiros) {
                if (!neiro.typeEnum.equals(TypeEnum.HIDDEN))
                    continue;

                neiro.delta = 0F;
                for (Link link : neiro.outputs) {
                    Neiro neiroOutput = link.output;
                    neiro.delta += neiroOutput.delta * link.weight;

                    //Также, для каждой исходящией связи данного нейрона сразу скорректируем её вес так как все данные у нас для этого уже есть
                    link.weightCorrect(lr, moment);
                }
                neiro.delta = neiro.derivative(neiro.valueOutput) * neiro.delta;
            }
        }

        //для нейронов входного слоя корректируем веса
        for (Neiro neiro : inputLayer.neiros)
            for (Link link : neiro.outputs)
                link.weightCorrect(lr, moment);
    }
}

Аналогичным образом выбирать между тренировкой сети на 60-и тысячах входящих примерах и на 100-а входящих примерах можно раскоменатиривая и закоментаривая следующие строчки в Base.java

        //prepareData(targetsTrain, datasTrain, "mnist_train_100.csv");
        prepareData(targetsTrain, datasTrain, "mnist_train.csv");
        ...
        //prepareData(targetsTest, datasTest, "mnist_test_10.csv");
        prepareData(targetsTest, datasTest, "mnist_test.csv");

Сеть имеет ту же самую структуру

        Layer inputLayer = Layer.createLayerInput(784, FunEnum.NONE);
        Neiro biasInput = inputLayer.addBias();
        Layer hiddenLayer = Layer.createLayerHidden(100, FunEnum.SIGMOID, inputLayer);
        Neiro biasHidden = hiddenLayer.addBias();
        Layer outputLayer = Layer.createLayerOutput(10, FunEnum.SIGMOID, hiddenLayer);
        Net net = new Net(List.of(inputLayer, hiddenLayer, outputLayer), List.of(biasInput, biasHidden));

        net.trains(datasTrain, targetsTrain,0.7F, 0.1F, 1 , 0.02F);
        //0,7 коэфф обучения, 0,1 - момент обучения, 1 - количество эпох обучения, 0,02 минимальная среднеквадратичная ошибка

Зависимость качества обучения от коэффициента обучения

net.trains(datasTrain, targetsTrain,0.7F, 0.1F, 1 , 0.02F);
epoch=0, mse=0.01831004248640159, duration(ms)=21943
eff=0.9442
То есть время обучения 21 сек (против 6 или 7 сек на питоне с матрицами). Зато эффективность обучения 0,9442 (против 0.9439 - лучшей которую удалось получить на питоне в той же конфигурации. Это объясняется тем, что в данном алгоритме исп момент обучения и он явно улучшает качество обучения).

net.trains(datasTrain, targetsTrain,0.1F, 0.1F, 1 , 0.02F)
epoch=0, mse=0.021802732442646366, duration(ms)=23780
eff=0.9383
Как видим - изменение коэффициента обучения особенно ничего не изменило. Это связано с избыточным количеством входных данных для обучения.

Отлично, а теперь тоже самое, но насчёт момента обучения
net.trains(datasTrain, targetsTrain,0.7F, 0.5F, 1 , 0.02F);
epoch=0, mse=0.0233378558680697, duration(ms)=22364
eff=0.9236
Видим что результат стал хуже при увеличении момента (что вполне логично ибо мы стали больше "прыгать" вокруг правильного значения. С другой стороны - больший момент поможет нам "выпрыгнуть" из локального минимума функции).
А вывод здесь такой: и коэффициент обучения и момент обучения следует изменять в процессе обучения для достижения наилучшей скорости и качества обучения.

Ещё один эксперимент. Что будет при увеличении циклов обучения. В примере с питоном не было ничего, а здесь
net.trains(datasTrain, targetsTrain,0.7F, 0.1F, 3 , 0.01F);
epoch=2, mse=0.018525838954432314, duration(ms)=21385
epoch=1, mse=0.011429854375592756, duration(ms)=20562
epoch=0, mse=0.008435301299220906, duration(ms)=20584
eff=0.9541
Мы видим как последовательно снижается общая среднеквадратичная ошибка, соответственно и общая эффективность вырастает до 0,9598
Эта модель сохранена через Net.save("file_net",net, eff) в файле file_net_addBias_h1x101_e3_0.008435301299220906_0.9541 который вы можете загрузить через Net net= Net.load("file_net_addBias_h1x101_e3_0.008435301299220906_0.9541")

Дальше рассмотрим java-neiro/src/main/java/neiro2_layer_event/Base.java.
Здесь реализация отличается в том, что вычисления перенесены на сторону нейрона, а нейросеть просто запускает их в определённом порядке и время от времени проверяет состояние слоя как совокупности нейронов на тему закончил он прямой и/или обратный процесс распространения

/**Описывает нейросеть как последовательность слоёв нейронов где каждый нейрон слоя N связан с каждым нейроном слоя N+1*/
public class Net implements Serializable {
    Layer inputLayer; //входной слой
    Layer outputLayer;//выходной слой
    List<Neiro> allNeiro; //все нейроны нейросети со всех слоёв
  
    /**Прямое распространение сигнала от входного слоя к выходному
     * @param data - входные данные для нейронов входного слоя
     * @return - массив данных полученных на нейронах выходного слоя*/
    public float[] calculate(float[] data){
        setData(data);

        this.outputLayer.countNeiroOnForward=0;
        while (!this.outputLayer.isOnReadyForward()){
            this.allNeiro.forEach(Neiro::forwardProcess);
            //System.out.println(this.outputLayer.neiros.size()-this.outputLayer.countNeiroOnForward );
        }

        return getData();
    }

      /**Обратное респространение ошибки от нейронов выходного слоя к нейронам входного слоя с коррекцией весов связей участвующих в процессе
     * @param target - правильный выход нейронов выходного слоя. Обучение с учителем
     * @param lr - коэффициент обучения
     * @param moment -момент*/
    private void backward(float[] target, float lr, float moment) {
        setTarget(target);

        this.inputLayer.countNeiroOnBackward=0;
        while (!this.inputLayer.isOnReadyBackward()){
            this.allNeiro.forEach(neiro -> neiro.backwardProcess(lr, moment));
        }
    }

  }

/**Описывает слой как совокупность нейронов с которыми связаны нейроны предыдущего слоя и которые сами связаны с нейронами следующего слоя*/
public class Layer implements Serializable {
      List<Neiro> neiros = new ArrayList<>(); //совокупность нейронов
      int countNeiroOnForward =0;//только для выходного слоя: кол нейронов из слоя завершивших процесс прямого распространения сигнала
      int countNeiroOnBackward=0;//только для выходного слоя: кол нейронов из слоя завершивших процесс обратного распространения ошибки
}

Запустив код на трёх эпохах обучения получаем
net.trains(datasTrain, targetsTrain,0.7F, 0.1F, 3, 0.01F);
epoch=2, mse=0.01783695361115406, duration(ms)=50438
epoch=1, mse=0.011128830660291252, duration(ms)=49615
epoch=0, mse=0.009242545514630088, duration(ms)=49741
eff=0.9612
Видим что время выполнения одной итерации цикла выросло почти вдвое (целых 50 сек против 21 у прошлого варианта (и 7 сек у кода на питоне))).
Это объясняется тем, что теперь нейроны сами должны извещать свои слои о своей "готовности".
Эффективность тоже немного выросла, но это, полагаю, случайна флюктуация.

В следующем проекте java-neiro/src/main/java/neiro3_virual_process/Base.java мы пойдём ещё дальше и запустим для каждого нейрона свой отдельный вычислительный процесс

        threadRuning=true;
        for (int i=0;i <allNeiro.length; i++){
            int finalI = i;
            Thread.ofVirtual().start(() -> performTask(this, finalI));
        }
        System.out.println("all thread is started");


    private static void performTask(Net net, int index) {
        while (net.threadRuning){
            net.allNeiro[index].process(net.lr, net.moment);
            Thread.yield();
        }
    }

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

Наконец наш последний вариант java-neiro/src/main/java/neiro4/Base.java
Нейросеть здесь ничего не знает про слои которые используются только для создания нейронов и связей между ними.
Логика работы вынесена на сторону нейрона, но нейроны запускаются со стороны нейросети в нужном порядке.

Запустим на 6-и эпохах обучения
net.trains(datasTrain, targetsTrain,0.7F, 0.1F,6, 0.01F);
epoch=5, mse=0.018179849441084077, duration(ms)=23192
epoch=4, mse=0.010713185539254865, duration(ms)=25328
epoch=3, mse=0.009506914157621945, duration(ms)=22540
eff=0.9606
Как видим алгоритм остановился на 3-х циклах обучения так как ошибка снизилась ниже минимального значения.
Время выполнения сравнимо с реализацией neiro1.

Дополнительно укажу, что код используемый в статье можно найти в https://github.com/upswet/NeiroFirst

Общие соображения сравнения реализация через матрицы на питоне и через совокупность нейронов на jave

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

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

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

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

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

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

Комментарии (0)