分享

在C#语言中实现简单神经网络(使用面向对象编程和C#从头开始构建神经网络)

 山峰云绕 2023-05-29 发布于贵州

  (轴突和树突之间的这些间隙称为突触)


   (使用面向对象编程和C#从头开始构建神经网络)


https://m.toutiao.com/is/UmQLmnr/ 



我们常常忘记,我们站在巨人的肩膀上。机器学习、深度学习和人工智能已经获得了如此大的吸引力,以至于有许多可用的框架。今天,选择我们选择的框架(例如 ML.NET)并完全专注于我们试图解决的问题开始项目真的很容易。但是,有时最好停下来实际考虑我们正在使用的内容以及该东西的实际工作原理。

由于我本人是一名转向机器学习的软件开发人员,因此我决定使用面向对象编程和 C# 从头开始构建神经网络。这是因为我想拆分神经网络的构建块,并使用我已经知道的工具更多地了解它们。这样,我学到的不是两件事,而是一件。从那时起,我经常使用此解决方案向 .NET 开发人员解释深度学习概念。因此,在本文中,我们将介绍该解决方案。

在本文中,我们将介绍:

  1. 人工神经网络和面向对象编程?
  2. 人工神经网络——受生物学启发的想法
  3. 人工神经网络的主要组成部分
  4. 实现输入函数激活功能神经元连接层将一切整合在一起工作流程

1. 人工神经网络和面向对象编程?

每当我向该领域深陷(双关语)的人提出这个解决方案时,问题总是“是的,但为什么?事实上,这不是专业构建神经网络的方法。是的,我们可以将所有权重和偏差抽象为矩阵。是的,我们可以使用一些框架来很好地处理这些矩阵上的操作,例如 PyTorch。

是的,有些人可能认为这种思维练习是多余的。他们可能是对的。但是,我一次又一次地使用此解决方案向具有软件开发背景的人解释和进一步介绍基本概念,他们喜欢它。学习曲线更快。在奠定了良好的基础之后,进一步的抽象出现了。所以,请耐心等待我

在本文中,采用了不太标准的方式和OO方法,而不是我们使用Python和R时通常的脚本观点。一篇非常好的文章以这种方式实现了这种实现,我强烈建议您浏览一下。

我想做的是分离每个组件和每个操作。最初只是一个思想练习,后来发展成为一个非常酷的迷你副项目。再次,我想强调的是,这并不是您通常实现网络的方式。应该使用更多的数学和矩阵乘法形式来优化整个过程。

除此之外,实现的网络代表了神经网络的一种简化的、最基本的形式。尽管如此,通过这种方式,人们可以看到一个人工神经网络的所有组件和元素,并更加熟悉这些概念。

2. 人工神经网络——一个受生物学启发的想法

人工神经网络是由我们的神经系统吸收的。一般的想法是,如果我们复制大脑结构,我们将构建一个学习机器。您可能知道,神经系统的最小单位是神经元。这些是具有相似和简单结构的细胞。

然而,通过持续通信,这些细胞获得了巨大的处理能力。如果你简单地说,神经元只是开关。如果这些开关接收到一定量的输入激励,则会产生输出信号。该输出信号是另一个神经元的输入。

每个神经元都有以下组件:

  • 身体,又称体
  • 枝 晶
  • 轴突

神经元的身体(体细胞)执行神经元的基本生命过程。每个神经元都有一个轴突。这是细胞的长部分;事实上,其中一些贯穿脊柱的整个长度。它就像一根电线,它是神经元的输出。另一方面,树突是神经元的输入,每个神经元都有多个树突。这些输入和输出,轴突和不同神经元的树突永远不会相互接触,即使它们靠近。

轴突和树突之间的这些间隙称为突触。通过这些突触,信号由神经递质分子携带。有各种神经递质化学物质,每种化学物质都服务于不同类型的神经元。其中包括著名的血清素和多巴胺。这些化学物质的数量和类型将决定神经元输入的“强度”。而且,如果所有树突上都有足够的输入,体细胞将“激发”轴突上的信号,并将其传递给下一个神经元。

3. 人工神经网络的主要组成部分

在深入研究代码之前,让我们先了解一下人工神经网络的结构。正如我们提到的,人工神经网络是生物驱动的,这意味着它们试图模仿真实神经系统的行为。

就像真实神经系统中最小的构建单元是神经元一样,人工神经网络也是如此——最小的构建单元是人工神经元。在真正的神经系统中,这些神经元通过突触相互连接,这为整个系统提供了巨大的处理能力、学习能力和巨大的灵活性。人工神经网络应用相同的原理。

通过连接人工神经元,他们的目标是创建一个类似的系统。他们将神经元分组到层中,然后在每的神经元之间建立连接。此外,通过为每个连接分配权重,a 他们能够从不重要的连接中过滤重要连接。

人工神经元的结构也是真实神经元的镜像结构。由于它们可以有多个输入,即输入连接,因此使用收集该数据的特殊函数 - 输入函数。通常用作神经元输入函数的函数是对输入连接上处于活动状态的所有加权输入求和的函数——加权输入函数。

每个人工神经元的另一个重要部分是激活功能。此函数定义此神经元是否将向其输出发送任何信号以及将哪个值传播到输出。基本上,此函数从输入函数接收一个值,并根据该值生成输出值并将其传播到输出。

4. 实施

因此,正如您从上一章中看到的那样,我们需要注意一些重要的实体,我们可以对其进行抽象。它们是神经元、连接、层和功能。在此解决方案中,一个单独的类将实现其中每个实体。然后,通过将它们放在一起并在其上添加反向传播算法,我们将实现这个简单的神经网络。

4.1 输入函数

如前所述,神经元的关键部分是输入函数和激活函数。让我们检查一下输入函数。首先,我为这个函数创建了一个接口,以便以后可以在神经元实现中轻松更改它:

public interface IInputFunction{ double CalculateInput(List<ISynapse> inputs);}

这些函数只有一个方法 – CalculateInput,它接收 ISynapse 接口中描述的连接列表。稍后我们将介绍此抽象;到目前为止,我们需要知道的是这个接口代表神经元之间的连接。计算输入方法需要根据连接列表中包含的数据返回某种值。然后,我做了输入函数的具体实现——加权和函数。

public class WeightedSumFunction : IInputFunction{    public double CalculateInput(List<ISynapse> inputs)    {        return inputs.Select(x => x.Weight * x.GetOutput()).Sum();    }}

此函数对列表中传递的所有连接的加权值求和。

4.2 激活函数

采用与输入函数实现相同的方法,首先实现激活函数的接口:

public interface IActivationFunction{ double CalculateOutput(double input);}

之后,可以进行具体的实施。计算输出方法应根据神经元从输入函数获取的输入值返回神经元的输出值。我喜欢有选择,所以我已经完成了之前一篇博客文章中提到的所有功能。以下是步骤函数的外观:

public class StepActivationFunction : IActivationFunction{    private double _treshold;    public StepActivationFunction(double treshold)    {        _treshold = treshold;    }    public double CalculateOutput(double input)    {        return Convert.ToDouble(input > _treshold);    }}

很简单,不是吗?在对象构造过程中定义阈值,如果输入值超过阈值,则 CalculateOutput 返回 1,否则返回 0。

其他功能也很容易。以下是 Sigmoid 激活函数实现:

public class SigmoidActivationFunction : IActivationFunction{ private double _coeficient; public SigmoidActivationFunction(double coeficient) { _coeficient = coeficient; } public double CalculateOutput(double input) { return (1 / (1 + Math.Exp(-input * _coeficient))); }}

这是整流器激活函数实现:

public class RectifiedActivationFuncion : IActivationFunction{    public double CalculateOutput(double input)    {        return Math.Max(0, input);    }}

到目前为止一切顺利 - 我们已经实现了输入和激活函数,我们可以继续实现网络中更棘手的部分 - 神经元和连接。

4.3 神经元

神经元应遵循的工作流程如下:从一个或多个加权输入连接接收输入值。收集这些值并将它们传递给激活函数,该函数计算神经元的输出值。将这些值发送到神经元的输出。基于神经元的工作流抽象,创建了以下内容:

public interface INeuron { Guid Id { get; } double PreviousPartialDerivate { get; set; } List<ISynapse> Inputs { get; set; } List<ISynapse> Outputs { get; set; } void AddInputNeuron(INeuron inputNeuron); void AddOutputNeuron(INeuron inputNeuron); double CalculateOutput(); void AddInputSynapse(double inputValue); void PushValueOnInput(double inputValue); }

在我们解释每个属性和方法之前,让我们看看神经元的具体实现,因为这将使它的工作方式更加清晰:

public class Neuron : INeuron{    private IActivationFunction _activationFunction;    private IInputFunction _inputFunction;    /// <summary>    /// Input connections of the neuron.    /// </summary>    public List<ISynapse> Inputs { get; set; }    /// <summary>    /// Output connections of the neuron.    /// </summary>    public List<ISynapse> Outputs { get; set; }    public Guid Id { get; private set; }    /// <summary>    /// Calculated partial derivate in previous iteration of training process.    /// </summary>    public double PreviousPartialDerivate { get; set; }    public Neuron(IActivationFunction activationFunction, IInputFunction inputFunction)    {        Id = Guid.NewGuid();        Inputs = new List<ISynapse>();        Outputs = new List<ISynapse>();        _activationFunction = activationFunction;        _inputFunction = inputFunction;    }    /// <summary>    /// Connect two neurons.     /// This neuron is the output neuron of the connection.    /// </summary>    /// <param name='inputNeuron'>Neuron that will be input neuron of the newly created connection.</param>    public void AddInputNeuron(INeuron inputNeuron)    {        var synapse = new Synapse(inputNeuron, this);        Inputs.Add(synapse);        inputNeuron.Outputs.Add(synapse);    }    /// <summary>    /// Connect two neurons.     /// This neuron is the input neuron of the connection.    /// </summary>    /// <param name='outputNeuron'>Neuron that will be output neuron of the newly created connection.</param>    public void AddOutputNeuron(INeuron outputNeuron)    {        var synapse = new Synapse(this, outputNeuron);        Outputs.Add(synapse);        outputNeuron.Inputs.Add(synapse);    }    /// <summary>    /// Calculate output value of the neuron.    /// </summary>    /// <returns>    /// Output of the neuron.    /// </returns>    public double CalculateOutput()    {        return _activationFunction.CalculateOutput(_inputFunction.CalculateInput(this.Inputs));    }    /// <summary>    /// Input Layer neurons just receive input values.    /// For this they need to have connections.    /// This function adds this kind of connection to the neuron.    /// </summary>    /// <param name='inputValue'>    /// Initial value that will be 'pushed' as an input to connection.    /// </param>    public void AddInputSynapse(double inputValue)    {        var inputSynapse = new InputSynapse(this, inputValue);        Inputs.Add(inputSynapse);    }    /// <summary>    /// Sets new value on the input connections.    /// </summary>    /// <param name='inputValue'>    /// New value that will be 'pushed' as an input to connection.    /// </param>    public void PushValueOnInput(double inputValue)    {        ((InputSynapse)Inputs.First()).Output = inputValue;    }}

每个神经元都有其唯一的标识符 – Id。此属性稍后将在反向传播算法中使用。为反向传播目的添加的另一个属性是 PreviousPartial Derivate,但这将进一步详细研究。神经元有两个列表,一个用于输入连接 – 输入,另一个用于输出连接 – 输出。 此外,它还有两个字段,每个字段对应前面章节中描述的每个函数。它们通过构造函数初始化。这样,可以创建具有不同输入和激活函数的神经元。

这个类也有一些有趣的方法。AddInputNeuronAddOutputNeuron 用于在神经元之间创建连接。第一个为某个神经元添加输入连接,第二个为某个神经元添加输出连接。AddInputSynapse将InputSynapse添加到神经元,这是一种特殊类型的连接。这些是仅用于神经元输入层的特殊连接,即它们仅用于向整个系统添加输入。这将在下一章中更详细地介绍。

最后但并非最不重要的一点是,计算输出方法用于激活输出计算的连锁反应。调用此函数时会发生什么?好吧,这将调用输入函数,该函数将从所有输入连接请求值。反过来,这些连接将请求来自这些连接的输入神经元的输出值,即来自前一层的神经元的输出值。此过程将一直完成,直到到达输入层并通过系统传播输入值。

4.4 连接

连接通过 ISynapse 接口抽象化:

public interface ISynapse{ double Weight { get; set; } double PreviousWeight { get; set; } double GetOutput(); bool IsFromNeuron(Guid fromNeuronId); void UpdateWeight(double learningRate, double delta);}

每个连接都有其权重通过同名属性表示。附加属性 PreviousWeight 被添加,并在错误通过系统反向传播期间使用它。当前权重的更新和前一个权重的存储是在帮助程序函数 UpdateWeight 中完成的。

还有另一个辅助函数 – IsFromNeuron,它可以检测某个神经元是否是连接的输入神经元。当然,有一个方法可以获取连接的输出值 – GetOutput。 以下是连接的实现:

public class Synapse : ISynapse{    internal INeuron _fromNeuron;    internal INeuron _toNeuron;    /// <summary>    /// Weight of the connection.    /// </summary>    public double Weight { get; set; }    /// <summary>    /// Weight that connection had in previous itteration.    /// Used in training process.    /// </summary>    public double PreviousWeight { get; set; }    public Synapse(INeuron fromNeuraon, INeuron toNeuron, double weight)    {        _fromNeuron = fromNeuraon;        _toNeuron = toNeuron;        Weight = weight;        PreviousWeight = 0;    }    public Synapse(INeuron fromNeuraon, INeuron toNeuron)    {        _fromNeuron = fromNeuraon;        _toNeuron = toNeuron;        var tmpRandom = new Random();        Weight = tmpRandom.NextDouble();        PreviousWeight = 0;    }    /// <summary>    /// Get output value of the connection.    /// </summary>    /// <returns>    /// Output value of the connection.    /// </returns>    public double GetOutput()    {        return _fromNeuron.CalculateOutput();    }    /// <summary>    /// Checks if Neuron has a certain number as an input neuron.    /// </summary>    /// <param name='fromNeuronId'>Neuron Id.</param>    /// <returns>    /// True - if the neuron is the input of the connection.    /// False - if the neuron is not the input of the connection.     /// </returns>    public bool IsFromNeuron(Guid fromNeuronId)    {        return _fromNeuron.Id.Equals(fromNeuronId);    }    /// <summary>    /// Update weight.    /// </summary>    /// <param name='learningRate'>Chossen learning rate.</param>    /// <param name='delta'>Calculated difference for which weight of the connection needs to be modified.</param>    public void UpdateWeight(double learningRate, double delta)    {        PreviousWeight = Weight;        Weight += learningRate * delta;    }}

请注意字段_fromNeuron和_toNeuron,它们定义了该突触连接的神经元。除了这种连接的实现之外,我在上一章中提到的另一个关于神经元的实现。它是输入突触,它被用作系统的输入。这些连接的权重始终为 1,并且在训练过程中不会更新。这是它的实现:

public class InputSynapse : ISynapse{ internal INeuron _toNeuron; public double Weight { get; set; } public double Output { get; set; } public double PreviousWeight { get; set; } public InputSynapse(INeuron toNeuron) { _toNeuron = toNeuron; Weight = 1; } public InputSynapse(INeuron toNeuron, double output) { _toNeuron = toNeuron; Output = output; Weight = 1; PreviousWeight = 1; } public double GetOutput() { return Output; } public bool IsFromNeuron(Guid fromNeuronId) { return false; } public void UpdateWeight(double learningRate, double delta) { throw new InvalidOperationException('It is not allowed to call this method on Input Connecion'); }}

4.5 层

从这里开始,神经层的实现非常简单:

public class NeuralLayer{    public List<INeuron> Neurons;    public NeuralLayer()    {        Neurons = new List<INeuron>();    }    /// <summary>    /// Connecting two layers.    /// </summary>    public void ConnectLayers(NeuralLayer inputLayer)    {        var combos = Neurons.SelectMany(neuron => inputLayer.Neurons, (neuron, input) => new { neuron, input });        combos.ToList().ForEach(x => x.neuron.AddInputNeuron(x.input));    }}

它包含该层中使用的神经元列表和用于将两层粘合在一起的ConnectLayers方法

4.6 简单的人工神经网络

现在,让我们把所有这些放在一起,并向其添加反向传播。看看网络本身的实现:

public class SimpleNeuralNetwork{ private NeuralLayerFactory _layerFactory; internal List<NeuralLayer> _layers; internal double _learningRate; internal double[][] _expectedResult; /// <summary> /// Constructor of the Neural Network. /// Note: /// Initialy input layer with defined number of inputs will be created. /// </summary> /// <param name='numberOfInputNeurons'> /// Number of neurons in input layer. /// </param> public SimpleNeuralNetwork(int numberOfInputNeurons) { _layers = new List<NeuralLayer>(); _layerFactory = new NeuralLayerFactory(); // Create input layer that will collect inputs. CreateInputLayer(numberOfInputNeurons); _learningRate = 2.95; } /// <summary> /// Add layer to the neural network. /// Layer will automatically be added as the output layer to the last layer in the neural network. /// </summary> public void AddLayer(NeuralLayer newLayer) { if (_layers.Any()) { var lastLayer = _layers.Last(); newLayer.ConnectLayers(lastLayer); } _layers.Add(newLayer); } /// <summary> /// Push input values to the neural network. /// </summary> public void PushInputValues(double[] inputs) { _layers.First().Neurons.ForEach(x => x.PushValueOnInput(inputs[_layers.First().Neurons.IndexOf(x)])); } /// <summary> /// Set expected values for the outputs. /// </summary> public void PushExpectedValues(double[][] expectedOutputs) { _expectedResult = expectedOutputs; } /// <summary> /// Calculate output of the neural network. /// </summary> /// <returns></returns> public List<double> GetOutput() { var returnValue = new List<double>(); _layers.Last().Neurons.ForEach(neuron => { returnValue.Add(neuron.CalculateOutput()); }); return returnValue; } /// <summary> /// Train neural network. /// </summary> /// <param name='inputs'>Input values.</param> /// <param name='numberOfEpochs'>Number of epochs.</param> public void Train(double[][] inputs, int numberOfEpochs) { double totalError = 0; for(int i = 0; i < numberOfEpochs; i++) { for(int j = 0; j < inputs.GetLength(0); j ++) { PushInputValues(inputs[j]); var outputs = new List<double>(); // Get outputs. _layers.Last().Neurons.ForEach(x => { outputs.Add(x.CalculateOutput()); }); // Calculate error by summing errors on all output neurons. totalError = CalculateTotalError(outputs, j); HandleOutputLayer(j); HandleHiddenLayers(); } } } /// <summary> /// Hellper function that creates input layer of the neural network. /// </summary> private void CreateInputLayer(int numberOfInputNeurons) { var inputLayer = _layerFactory.CreateNeuralLayer(numberOfInputNeurons, new RectifiedActivationFuncion(), new WeightedSumFunction()); inputLayer.Neurons.ForEach(x => x.AddInputSynapse(0)); this.AddLayer(inputLayer); } /// <summary> /// Hellper function that calculates total error of the neural network. /// </summary> private double CalculateTotalError(List<double> outputs, int row) { double totalError = 0; outputs.ForEach(output => { var error = Math.Pow(output - _expectedResult[row][outputs.IndexOf(output)], 2); totalError += error; }); return totalError; } /// <summary> /// Hellper function that runs backpropagation algorithm on the output layer of the network. /// </summary> /// <param name='row'> /// Input/Expected output row. /// </param> private void HandleOutputLayer(int row) { _layers.Last().Neurons.ForEach(neuron => { neuron.Inputs.ForEach(connection => { var output = neuron.CalculateOutput(); var netInput = connection.GetOutput(); var expectedOutput = _expectedResult[row][_layers.Last().Neurons.IndexOf(neuron)]; var nodeDelta = (expectedOutput - output) * output * (1 - output); var delta = -1 * netInput * nodeDelta; connection.UpdateWeight(_learningRate, delta); neuron.PreviousPartialDerivate = nodeDelta; }); }); } /// <summary> /// Hellper function that runs backpropagation algorithm on the hidden layer of the network. /// </summary> /// <param name='row'> /// Input/Expected output row. /// </param> private void HandleHiddenLayers() { for (int k = _layers.Count - 2; k > 0; k--) { _layers[k].Neurons.ForEach(neuron => { neuron.Inputs.ForEach(connection => { var output = neuron.CalculateOutput(); var netInput = connection.GetOutput(); double sumPartial = 0; _layers[k + 1].Neurons .ForEach(outputNeuron => { outputNeuron.Inputs.Where(i => i.IsFromNeuron(neuron.Id)) .ToList() .ForEach(outConnection => { sumPartial += outConnection.PreviousWeight * outputNeuron.PreviousPartialDerivate; }); }); var delta = -1 * netInput * sumPartial * output * (1 - output); connection.UpdateWeight(_learningRate, delta); }); }); } }}

此类包含神经层列表和层工厂(用于创建新层的类)。在对象构建期间,初始输入层将添加到网络中。其他图层通过函数 AddLayer 添加,该函数在当前图层列表的顶部添加一个传递的图层。GetOutput 方法将激活网络的输出层,从而通过网络启动连锁反应。

此外,此类还有一些帮助程序方法,例如 PushExpectValues,用于为训练期间将传递的训练集设置所需值,以及 PushInputValues,用于设置网络的某些输入。

此类最重要的方法是 Train 方法。它接收训练集和纪元数。对于每个 epoch,它通过网络运行整个训练集,如本文所述。然后,将输出与所需的输出进行比较,并调用函数 HandleOutputLayer HandleHiddenLayer。这些函数实现本文中所述的反向传播算法。

4.7 工作流程

典型的工作流程可以在其中一个测试中看到 -
Train_RuningTraining_NetworkIsTrained。
它是这样的:

var network = new SimpleNeuralNetwork(3);var layerFactory = new NeuralLayerFactory();network.AddLayer(layerFactory.CreateNeuralLayer(3, new RectifiedActivationFuncion(), new WeightedSumFunction()));network.AddLayer(layerFactory.CreateNeuralLayer(1, new SigmoidActivationFunction(0.7), new WeightedSumFunction()));network.PushExpectedValues(    new double[][] {        new double[] { 0 },        new double[] { 1 },        new double[] { 1 },        new double[] { 0 },        new double[] { 1 },        new double[] { 0 },        new double[] { 0 },    });network.Train(    new double[][] {        new double[] { 150, 2, 0 },        new double[] { 1002, 56, 1 },        new double[] { 1060, 59, 1 },        new double[] { 200, 3, 0 },        new double[] { 300, 3, 1 },        new double[] { 120, 1, 0 },        new double[] { 80, 1, 0 },    }, 10000);network.PushInputValues(new double[] { 1054, 54, 1 });var outputs = network.GetOutput();

首先,创建一个神经网络对象。在构造函数中,定义输入层中将有三个神经元。之后,使用函数添加层和层工厂添加两个层。对于每一层,定义每个神经元的神经元和功能的数量。完成此部分后,将定义预期的输出,并调用具有输入训练集和 epoch 数的 Train 函数。

结论

神经网络的这种实现远非最佳。您会注意到很多嵌套的 for 循环,这些循环的性能肯定很差。此外,为了简化此解决方案,例如,在实现、动量和偏差的第一次迭代中没有引入神经网络的某些组件。然而,实现高性能网络的目标不是,而是分析和显示每个人工神经网络具有的重要元素和抽象。
感谢您的阅读!

原文标题:Implementing Simple Neural Network in C#

原文链接:
https:///2022/07/04/implementing-simple-neural-network-in-c/

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多