Здравствуй, Хабр! Данная статья предназначена для тех, кто приблизительно шарит в математических принципах работы нейронных сетей и в их сути вообще, поэтому советую ознакомиться с этим перед прочтением. Хоть как-то понять, что происходит можно сначала здесь, потом тут.
Недавно мне пришлось сделать нейросеть для распознавания рукописных цифр(сегодня будет не совсем её код) в рамках школьного проекта, и, естественно, я начал разбираться в этой белиберде теме. Посмотрев приблизительно достаточно об этом в интернете, я понял чуть более, чем ничего. Но неожиданно(как это обычно бывает) получилось наткнуться на книгу Саймона Хайкина(не знаю почему раньше не загуглил). И тогда началось потное вкуривание матчасти нейросетей, состоящее из одного матана.
На самом деле, несмотря на обилие математики, она не такая уж и запредельно сложная. Понять сатанистские каракули и письмена этого пособия сможет среднестатистический 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.
Функция активации — логистическая сигмоидальная.
Потом надо осознать, что нам нужно куда-то записывать веса, проводить вычисления, немного дебажить, ну и кортежи поюзать(но для них юзинг мне не нужен). Соответственно, 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();//чтоб консоль не закрывалась :) } }
Результат обучения.
Итого, путём насилования мозга несложных манипуляций, мы получили основу работающей нейронной сети. Для того, чтобы заставить её делать что-либо другое, достаточно поменять класс InputLayer и подобрать параметры сети для новой задачи. Через время(какое конкретно не знаю) напишу продолжение этой статьи с руководством по созданию с нуля свёрточной нейронной сети на C# и здесь сделаю апдейт этой с ссылками на MLP-рекогнитор картинок MNIST(но это не точно) и код статьи на Python(точно, но дольше ждать).
За сим всё, буду рад ответить на вопросы в комментариях, а пока извольте, новые дела ждут.
P.S.: Для желающих помацать код клацать.
P.P.S.: Сеть по ссылке выше — потненькая необученная няша-стесняша.