我们常常忘记,我们站在巨人的肩膀上。机器学习、深度学习和人工智能已经获得了如此大的吸引力,以至于有许多框架可用。今天,我们真的很容易选择框架(例如**ML.NET**),然后开始项目,完全关注我们要解决的问题。然而,有时停下来,真正考虑一下我们正在使用的东西,以及这个东西究竟是如何工作的,是一件好事。
因为我自己是一个转向机器学习的软件开发者,当年我决定使用面向对象编程和C#从头开始建立神经网络。这是因为我想把神经网络的构件拆开,用我已经知道的工具来学习更多的知识。这样一来,我就不是在学习两样东西,而是只学习一样。从那时起,我经常用这个方案来向.NET开发者解释深度学习的概念。因此,在这篇文章中,我们将介绍这个解决方案。
在这篇文章中,我们将介绍:
- 人工神经网络和面向对象编程?
- 人工神经网络--一个受生物学启发的想法
- 人工神经网络的主要组成部分
- 实施
- 输入函数
- 激活功能
- 神经元
- 连接
- 层数
- 把它放在一起
- 工作流程
1.人工神经网络和面向对象的编程?
每当我把这个方案介绍给在这个领域有深度(双关语)的人时,问题总是 "是的,但是为什么?"。的确,这不是一个专业构建神经网络的方法。是的,我们可以将所有的权重和偏差抽象为矩阵。是的,我们可以使用一些特别好地处理这些矩阵的操作的框架,比如说PyTorch。
是的,有些人可能认为这种思考是多余的。而且他们可能是对的。然而,我一次又一次地用这个方案向有软件开发背景的人解释和进一步介绍基本概念,他们都很喜欢。学习曲线就是这么快。在奠定了良好的基础之后,进一步的抽象才会出现。所以,请忍受我 🙂
在这篇文章中,我们采取了不太标准的方式和OO方法,而不是像我们使用Python和R那样的通常的脚本观点。文章做了这样的实现,我强烈建议你浏览一下它。
我想做的是把每个组件和每个操作都分开。最初只是一个思考练习,后来发展成相当酷的小型副业。我想再次强调的是,这并不是你一般实现网络的真正方式。应该使用更多的数学和矩阵乘法的形式来优化整个过程。
除此之外,实现的网络代表了一个简化的、最基本的神经网络的形式。尽管如此,人们可以通过这种方式看到一个人工神经网络的所有组成部分和元素,并对这些概念更加熟悉。
2.人工神经网络--一个受生物学启发的想法
人工神经网络是由我们的神经系统启发的。一般的想法是,如果我们复制大脑结构,我们将建立一个学习机器。 你可能知道,神经系统的最小单位是一个神经元。这些是具有类似和简单结构的细胞。
然而,通过不断的交流,这些细胞实现了巨大的处理能力。如果用简单的话来说,神经元只是开关。这些开关如果收到一定量的输入刺激,就会产生一个输出信号。这个输出信号是另一个神经元的输入。
每个神经元都有这些组成部分:
- 本体,也称为体细胞
- 树突
- 轴突
神经元的主体(soma)执行神经元的基本生命过程。每个神经元都有一个轴突。这是细胞的一个长部分;事实上,有些轴突贯穿整个脊柱的长度。它的作用就像一根电线,是神经元的一个输出。另一方面,树突是神经元的输入,每个神经元有多个树突。不同神经元的这些输入和输出、轴突和树突即使靠近,也从不相互接触。
轴突和树突之间的这些空隙被称为突触。通过这些突触,信号由神经递质分子携带。 有各种神经递质化学品,每种都为不同类型的神经元服务。其中有著名的5-羟色胺和多巴胺。这些化学物质的数量和类型将决定对神经元的输入有多 "强"。而且,如果所有树突上都有足够的输入,体细胞将 "点燃 "轴突上的信号,并将其传输到下一个神经元。
3.人工神经网络的主要组成部分
在我们深入研究代码之前,让我们先了解一下人工神经网络的结构。正如我们所提到的,人工神经网络是以生物为动力的,这意味着它们正试图模仿真实的神经系统的行为。
就像真实神经系统中最小的构建单元是神经元一样,人工神经网络也是如此,最小的构建单元是人工神经元。在真正的神经系统中,这些神经元通过突触相互连接,这使整个系统具有巨大的处理能力、学习能力和巨大的灵活性。人工神经网络也应用了同样的原理。
通过连接人工神经元,他们旨在创建一个类似的系统。他们将神经元分组成层,然后在每层的神经元之间建立连接。此外,通过给每个连接 分配权重 ,a 他们能够过滤重要和不重要的连接。
人工神经元的结构也是真实神经元的镜像结构。由于它们可以有多个输入,即输入连接,所以使用了一个收集这些数据的特殊函数--输入函数。在神经元中通常被用作输入函数的函数是将所有在输入连接上活跃的加权输入相加的函数--加权输入函数。
每个人工神经元的另一个重要部分是***激活函数***.这个函数定义了这个神经元是否会向其输出发送任何信号,以及哪个值会被传播到输出。基本上,这个函数从输入函数接收一个值,根据这个值,它产生一个输出值,并将它们传播到输出。
4.实施
因此,正如你在前一章中所看到的,有几个重要的实体是我们需要注意的,并且我们可以将其抽象出来。它们是神经元、连接、层和函数。在这个解决方案中,一个单独的类将实现这些实体中的每一个。然后,通过把它们放在一起并在上面添加一个逆传播算法在它上面,我们就有了这个简单神经网络的实现。
4.1 输入函数
如前所述,神经元的关键部分是输入函数和激活函数。让我们来研究一下输入函数。首先,我为这个函数创建了一个接口,这样在以后的神经元实现中可以很容易地改变它。
public interface IInputFunction
{
double CalculateInput(List<ISynapse> inputs);
}
这些函数只有一个方法--CalculateInput,它接收一个在ISynapse接口中描述的连接列表。我们将在后面介绍这个抽象;到目前为止,我们需要知道的是这个接口代表了神经元之间的连接。CalculateInput方法需要根据连接列表中的数据返回某种值。然后,我做了输入函数的具体实现--加权和函数。
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);
}
之后,就可以进行具体的实现了。CalculateOutput方法应该根据它从输入函数中得到的输入值来返回神经元的输出值。 我喜欢有选择,所以我做了所有函数中提到的前面的一篇博文.下面是步骤函数的样子。
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。这个属性将在后面的反向传播算法中使用。另一个为反向传播目的而添加的属性是PreviousPartialDerivate,但这将在后面详细研究。一个神经元有两个列表,一个是输入连接--Inputs, 另一个是输出连接--Outputs。 同时,它有两个字段,前几章中描述的函数各有一个。它们通过构造函数被初始化。这样,就可以创建具有不同输入和激活函数的神经元。
这个类也有一些有趣的方法。AddInputNeuron和AddOutputNeuron用于创建神经元之间的连接。第一个是给某个神经元添加输入连接,第二个是给某个神经元添加输出连接。AddInputSynapse给神经元添加InputSynapse,这是一种特殊类型的连接。这些特殊的连接只是用于神经元的输入层,也就是说,它们只用于向整个系统添加输入。这将在下一章更详细地介绍。
最后但同样重要的是,CalculateOutput方法用于激活输出计算的连锁反应。当这个函数被调用时,会发生什么?嗯,这将调用输入函数,它将从所有的输入连接中请求值。反过来,这些连接将向这些连接的输入神经元请求输出值,也就是上一层的神经元的输出值。这个过程将一直进行,直到达到输入层,输入值在系统中传播。
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,它们定义了这个突触所连接的神经元。除了这个连接的实现之外,还有一个我在前一章提到的关于神经元的连接。它是 InputSynapse,它被用作系统的输入。这些连接的权重始终为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方法将激活网络的输出层,从而通过网络启动一个连锁反应。
此外,这个类还有一些辅助方法,如PushExpectedValues,用于为训练过程中传递的训练集设置期望值,以及PushInputValues, 用于为网络设置某些输入。
这个类中最重要的方法是Train方法。它接收训练集和epochs的数量。对于每个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();
首先,一个神经网络对象被创建。在构造函数中,定义了输入层将有三个神经元。之后,使用函数AddLayer和layer factory添加两个层。对于每一层,定义了神经元的数量和每个神经元的函数。这一部分完成后,定义预期输出,并调用带有输入训练集和历时数的Train函数。
总结
这个神经网络的实现远非最佳。你会注意到大量的嵌套for循环,这肯定会有不好的表现。另外,为了简化这个解决方案,神经网络的一些组件在第一次迭代实施时没有被引入,例如动量和偏置。尽管如此,我们的目标不是实现一个高性能的网络,而是分析和展示每个人工神经网络所具有的重要元素和抽象概念。
谢谢您的阅读!