Истинная реализация нейросети с нуля на языке программирования C#

МЕНЮ


Искусственный интеллект
Поиск
Регистрация на сайте
Помощь проекту

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


image

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

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

На самом деле, несмотря на обилие математики, она не такая уж и запредельно сложная. Понять сатанистские каракули и письмена этого пособия сможет среднестатистический 11-классник товарищ-физмат или 1~2-курсник технарьской шараги. Помимо этого, пусть книга достаточно объёмная и трудная для восприятия, но вещи, написанные в ней, реально объясняют, что "твориться у тачки под капотом". Как вы поняли я крайне рекомендую(ни в коем случае не рекламирую) "Нейронные сети. Полный курс" Саймона Хайкина к прочтению в том случае, если вам придётся столкнуться с применением/написанием/разработкой нейросетей и прочего подобного stuff'а. Хотя в ней нет материала про новомодные свёрточные сети, никто не мешает загуглить лекции от какого-нибудь харизматичного работника Yandex/Mail.ru/etc. никто не мешает.

Конечно, осознав устройство сеток, я не мог просто остановиться, так как впереди предстояло написание кода. В связи со своим параллельным занятием, заключающемся в создани игр на Unity, языком реализации оказался ламповый и няшный шарпей 7 версии(ибо она последняя актуальная). Именно в этот момент, оказавшись на просторах интернета, я понял, что число внятных туториалов по написанию нейросетей с нуля(без ваших фреймворков) на шарпе бесконечно мало. Ладно. Я мог использовать, всякие Theano и Tensor Flow, НО под капотом моей смерть-машины в моём ноутбуке стоит "красная" видеокарта без особой поддержки API, через которые обращаются к мощи GPU(ведь именно их и используют Theano/Tensor Flow/etc.).

Помогите школоте прошариться:

Моя видеокарта называется ATI Radeon HD Mobility 4570. И если кто знает, как обратиться к её мощностям для параллелизации нейросетевых вычислений, пожалуйста, напишите в комментарии. Тогда вы поможете мне, и возможно у этой статьи появится продолжение. Не осуждается предложение других ЯП.

Просто, как я понял, она настолько старая, что нифига не поддерживает. Может быть я не прав.


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

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

Прежде чем начать программировать это, можнонужно нарисовать на бумаге, дабы облегчить представление структуры и работы нейронки. Моё воображение вылилось в следующую картинку. И да, кстати, это консольное приложение в Visual Studio 2017, с .NET Framework версии 4.7.

Краткая инфа по сетке(для тех, кому это хоть о чём-то говорит)

Многослойный полносвязный персептрон.
Один скрытый слой.
4 нейрона в скрытом слое(на этом количестве персептрон сошёлся).
Алгоритм обучения — backpropagation.
Критерий останова — преодоление порогового значения среднеквадратичной ошибки по эпохе.(0.001)
Скорость обучения — 0.1.
Функция активации — логистическая сигмоидальная.


image
Потом надо осознать, что нам нужно куда-то записывать веса, проводить вычисления, немного дебажить, ну и кортежи поюзать(но для них юзинг мне не нужен). Соответственно, using'и у нас такие.

Also

В папке release||debug этого прожекта располагаются файлы(на каждый слой по одному) по имени типа (fieldname)_memory.xml сами знаете для чего. Они создаются заранее с учётом общего количества весов каждого слоя. Знаю, что XML — это не лучший выбор для парсинга, просто времени было немного на это дело.


using System.Xml; using static System.Math; using static System.Console;

Также вычислительные нейроны у нас двух типов: скрытые и выходные. А веса могут считываться или записываться в память. Реализуем сию концепцию двумя перечислениями.

enum MemoryMode { GET, SET } enum NeuronType { Hidden, Output }

Всё остальное будет происходить внутри пространства имён, которое я назову просто: Neural Network.

namespace NeuralNetwork { //всё, что будет описано ниже, располагается здесь }

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

class InputLayer     {         private (double[], double[])[] _trainset = new(double[], double[])[]//да-да, массив кортежей из 2 массивов         {             (new double[]{ 0, 0 }, new double[]{ 0, 1 }),             (new double[]{ 0, 1 }, new double[]{ 1, 0 }),             (new double[]{ 1, 0 }, new double[]{ 1, 0 }),             (new double[]{ 1, 1 }, new double[]{ 0, 1 })         };         //инкапсуляция едрид-мадрид         public (double[], double[])[] Trainset { get => _trainset; }//такие няшные свойства нынче в C# 7      }

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

class Neuron     {         public Neuron(double[] inputs, double[] weights, NeuronType type)         {             _type = type;             _weights = weights;             _inputs = inputs;         }         private NeuronType _type;         private double[] _weights;         private double[] _inputs;         public double[] Weights { get => _weights; set => _weights = value; }         public double[] Inputs { get => _inputs; set => _inputs = value; }         public double Output { get => Activator(_inputs, _weights); }         private double Activator(double[] i, double[] w)//преобразования         {             double sum = 0;             for (int l = 0; l < i.Length; ++l)                 sum += i[l] * w[l];//линейные             return Pow(1 + Exp(-sum), -1);//нелинейные         }         public double Derivativator(double outsignal) => outsignal * (1 - outsignal);//формула производной для текущей функции активации уже выведена в ранее упомянутой книге         public double Gradientor(double error, double dif, double g_sum) => (_type == NeuronType.Output) ? error * dif : g_sum * dif;//g_sum - это сумма градиентов следующего слоя     }

Ладно у нас есть нейроны, но их необходимо объединить в слои для вычислений. Возвращаясь к моей схеме выше, хочу объяснить наличие чёрного пунктира. Он разделяет слои так, чтобы показать, что они содержат. То есть один вычислительный слой содержит нейроны и веса для связи с нейронами предыдущего слоя. Нейроны объединяются массивом, а не списком, так как это менее ресурсоёмко. Веса организованы матрицей(двумерным массивом) размера(нетрудно догадаться) [число нейронов текущего слоя X число нейронов предыдущего слоя]. Естественно, слой инициализирует нейроны, иначе словим null reference. При этом эти слои очень похожи друг на друга, но имеют различия в логике, поэтому скрытые и выходной слои должны быть реализованы наследниками одного базового класса, который кстати оказывается абстрактным.

abstract class Layer//модификаторы protected стоят для внутрииерархического использования членов класса     {//type используется для связи с одноимённым полю слоя файлом памяти         protected Layer(int non, int nopn, NeuronType nt, string type)         {//увидите это в WeightInitialize             numofneurons = non;             numofprevneurons = nopn;             Neurons = new Neuron[non];             double[,] Weights = WeightInitialize(MemoryMode.GET, type);             for (int i = 0; i < non; ++i)             {                 double[] temp_weights = new double[nopn];                 for (int j = 0; j < nopn; ++j)                     temp_weights[j] = Weights[i, j];                 Neurons[i] = new Neuron(null, temp_weights, nt);//про подачу null на входы ниже             }         }         protected int numofneurons;//число нейронов текущего слоя         protected int numofprevneurons;//число нейронов предыдущего слоя         protected const double learningrate = 0.1d;//скорость обучения         Neuron[] _neurons;         public Neuron[] Neurons { get => _neurons; set => _neurons = value; }         public double[] Data//я подал null на входы нейронов, так как         {//сначала нужно будет преобразовать информацию             set//(видео, изображения, etc.)             {//а загружать input'ы нейронов слоя надо не сразу,                 for (int i = 0; i < Neurons.Length; ++i)                     Neurons[i].Inputs = value;             }//а только после вычисления выходов предыдущего слоя         }         public double[,] WeightInitialize(MemoryMode mm, string type)         {             double[,] _weights = new double[numofneurons, numofprevneurons];             WriteLine($"{type} weights are being initialized...");             XmlDocument memory_doc = new XmlDocument();             memory_doc.Load($"{type}_memory.xml");             XmlElement memory_el = memory_doc.DocumentElement;             switch (mm)             {                 case MemoryMode.GET:                     for (int l = 0; l < _weights.GetLength(0); ++l)                         for (int k = 0; k < _weights.GetLength(1); ++k)                             _weights[l, k] = double.Parse(memory_el.ChildNodes.Item(k + _weights.GetLength(1) * l).InnerText.Replace(',', '.'), System.Globalization.CultureInfo.InvariantCulture);//parsing stuff                     break;                 case MemoryMode.SET:                     for (int l = 0; l < Neurons.Length; ++l)                         for (int k = 0; k < numofprevneurons; ++k)                             memory_el.ChildNodes.Item(k + numofprevneurons * l).InnerText = Neurons[l].Weights[k].ToString();                     break;             }             memory_doc.Save($"{type}_memory.xml");             WriteLine($"{type} weights have been initialized...");             return _weights;         }         abstract public void Recognize(Network net, Layer nextLayer);//для прямых проходов         abstract public double[] BackwardPass(double[] stuff);//и обратных     }

Соль абстрактных классов

Класс Layer — это абстрактный класс, поэтому нельзя создавать его экземпляры. Это значит, что наше желание сохранить свойства "слоя" выполняется путём наследования родительского конструктора через ключевое слово base и пустой конструктор наследника в одну строчку(ибо вся логика конструктора определена в базовом классе, и её не надо переписывать).


Теперь непосредственно классы-наследники: Hidden и Output. Сразу два класса в цельном куске кода.

class HiddenLayer : Layer     {         public HiddenLayer(int non, int nopn, NeuronType nt, string type) : base(non, nopn, nt, type){}         public override void Recognize(Network net, Layer nextLayer)         {             double[] hidden_out = new double[Neurons.Length];             for (int i = 0; i < Neurons.Length; ++i)                 hidden_out[i] = Neurons[i].Output;             nextLayer.Data = hidden_out;         }         public override double[] BackwardPass(double[] gr_sums)         {             double[] gr_sum = null;             //сюда можно всунуть вычисление градиентных сумм для других скрытых слоёв             //но градиенты будут вычисляться по-другому, то есть             //через градиентные суммы следующего слоя и производные             for (int i = 0; i < numofneurons; ++i)                 for (int n = 0; n < numofprevneurons; ++n)                     Neurons[i].Weights[n] += learningrate * Neurons[i].Inputs[n] * Neurons[i].Gradientor(0, Neurons[i].Derivativator(Neurons[i].Output), gr_sums[i]);//коррекция весов             return gr_sum;         }     } class OutputLayer : Layer     {         public OutputLayer(int non, int nopn, NeuronType nt, string type) : base(non, nopn, nt, type){}         public override void Recognize(Network net, Layer nextLayer)         {             for (int i = 0; i < Neurons.Length; ++i)                 net.fact[i] = Neurons[i].Output;         }         public override double[] BackwardPass(double[] errors)         {             double[] gr_sum = new double[numofprevneurons];             for (int j = 0; j < gr_sum.Length; ++j)//вычисление градиентных сумм выходного слоя             {                 double sum = 0;                 for (int k = 0; k < Neurons.Length; ++k)                     sum += Neurons[k].Weights[j] * Neurons[k].Gradientor(errors[k], Neurons[k].Derivativator(Neurons[k].Output), 0);//через ошибку и производную                 gr_sum[j] = sum;             }             for (int i = 0; i < numofneurons; ++i)                 for (int n = 0; n < numofprevneurons; ++n)                     Neurons[i].Weights[n] += learningrate * Neurons[i].Inputs[n] * Neurons[i].Gradientor(errors[i], Neurons[i].Derivativator(Neurons[i].Output), 0);//коррекция весов             return gr_sum;         }     }

В принципе, всё самое важное я описал в комментариях. У нас есть все компоненты: обучающие и тестовые данные, вычислительные элементы, их "конгламераты". Теперь настало время всё связать обучением. Алгоритм обучения — backpropagation, следовательно критерий останова выбираю я, и выбор мой — есть преодоление порогового значения среднеквадратичной ошибки по эпохе, которое я выбрал равным 0.001. Для поставленной цели я написал класс Network, описывающий состояние сети, которое принимается в качестве параметра многих методов, как вы могли заметить.

class Network     {         //все слои сети         InputLayer input_layer = new InputLayer();         public HiddenLayer hidden_layer = new HiddenLayer(4, 2, NeuronType.Hidden, nameof(hidden_layer));         public OutputLayer output_layer = new OutputLayer(2, 4, NeuronType.Output, nameof(output_layer));         //массив для хранения выхода сети         public double[] fact = new double[2];//не ругайте за 2 пожалуйста         //ошибка одной итерации обучения         double GetMSE(double[] errors)         {             double sum = 0;             for (int i = 0; i < errors.Length; ++i)                 sum += Pow(errors[i], 2);             return 0.5d * sum;         }         //ошибка эпохи         double GetCost(double[] mses)         {             double sum = 0;             for (int i = 0; i < mses.Length; ++i)                 sum += mses[i];             return (sum / mses.Length);         }         //непосредственно обучение         static void Train(Network net)//backpropagation method         {             const double threshold = 0.001d;//порог ошибки             double[] temp_mses = new double[4];//массив для хранения ошибок итераций             double temp_cost = 0;//текущее значение ошибки по эпохе             do             {                 for (int i = 0; i < net.input_layer.Trainset.Length; ++i)                 {                     //прямой проход                     net.hidden_layer.Data = net.input_layer.Trainset[i].Item1;                     net.hidden_layer.Recognize(null, net.output_layer);                     net.output_layer.Recognize(net, null);                     //вычисление ошибки по итерации                     double[] errors = new double[net.input_layer.Trainset[i].Item2.Length];                     for (int x = 0; x < errors.Length; ++x)                         errors[x] = net.input_layer.Trainset[i].Item2[x] - net.fact[x];                     temp_mses[i] = net.GetMSE(errors);                     //обратный проход и коррекция весов                     double[] temp_gsums = net.output_layer.BackwardPass(errors);                     net.hidden_layer.BackwardPass(temp_gsums);                 }                 temp_cost = net.GetCost(temp_mses);//вычисление ошибки по эпохе                 //debugging                 WriteLine($"{temp_cost}");             } while (temp_cost > threshold);             //загрузка скорректированных весов в "память"             net.hidden_layer.WeightInitialize(MemoryMode.SET, nameof(hidden_layer));             net.output_layer.WeightInitialize(MemoryMode.SET, nameof(output_layer));         }         //тестирование сети         static void Test(Network net)         {             for (int i = 0; i < net.input_layer.Trainset.Length; ++i)             {                 net.hidden_layer.Data = net.input_layer.Trainset[i].Item1;                 net.hidden_layer.Recognize(null, net.output_layer);                 net.output_layer.Recognize(net, null);                 for (int j = 0; j < net.fact.Length; ++j)                     WriteLine($"{net.fact[j]}");                 WriteLine();             }         }         //запуск сети         static void Main(string[] args)         {             Network net = new Network();             Train(net);             Test(net);             ReadKey();//чтоб консоль не закрывалась :)         }     }

Результат обучения.
image

Итого, путём насилования мозга несложных манипуляций, мы получили основу работающей нейронной сети. Для того, чтобы заставить её делать что-либо другое, достаточно поменять класс InputLayer и подобрать параметры сети для новой задачи. Через время(какое конкретно не знаю) напишу продолжение этой статьи с руководством по созданию с нуля свёрточной нейронной сети на C# и здесь сделаю апдейт этой с ссылками на MLP-рекогнитор картинок MNIST(но это не точно) и код статьи на Python(точно, но дольше ждать).

За сим всё, буду рад ответить на вопросы в комментариях, а пока извольте, новые дела ждут.
P.S.: Для желающих помацать код клацать.
P.P.S.: Сеть по ссылке выше — потненькая необученная няша-стесняша.


Источник: habrahabr.ru

Комментарии: