赞
踩
注意:本篇为50天后的Java自学笔记扩充,内容不再是基础数据结构内容而是机器学习中的各种经典算法。这部分博客更侧重于笔记以方便自己的理解,自我知识的输出明显减少,若有错误欢迎指正!
本文是我计划描述BP神经网络的第一个博客, 全系列总共有三篇, 本篇主要讲述BP神经网络的基本概念与直观的, 固定的代码实现. 后续两篇将从神经网络的特性, 面向对象等特性入手重构BP神经网络的代码.
*相关文章目录*
*本篇目录*
没了解过机器学习的人可能都听说过“神经网络”这个大名,它实在是太出名以至于你看到各种描述计算机的高级内容时都会加上这个名讳来体现自己的 “高 大 上”。
神经网络(neural networks)这个概念最早并不是来自计算机,很自然,它最初是来自生物学中那些具有神经系统生物的神经突触网络,而后来通过模仿生物的这种应答机制延伸出各种“ 神经网络 ”的交叉研究。渐渐地,“ 神经网络 ”的含义变得更加宽泛了。用Kohonen在1988年给出的定义描述:“神经网络是由具有适应性的简单单元组成的广泛并行互连的网络,它的组织能够模拟生物神经系统对真实世界物体所作出的交互反应”
在机器学习中,机器的算法思路模仿了生物神经系统中突触接收了来自神经元的神经递质后只有达到一定阈值(电位变化)才触发兴奋反应的逻辑。最直观的一种抽象模型就是1943年由McCulloch和Pitts提出的“ M-P 神经元模型”
图中的
这里提到的函数
而机器学习在实际采用M-P神经元模型时,更多是使用平滑后的阶跃函数,即Sigmoid函数。这个函数把可能在较大范围内变化的输入值挤压到
其实在算法设计与代码编写时,并不用严格追究其是否完全符合生物特性,毕竟计算机的神经网络永远没有生物学习的效率与广泛性。在应用时,更多地将其抽象为合理的数学算式来考虑,例如公式1的内部形如
当然,因为外部有个
令
BP神经网络学习算法(逆误差传播算法:error BackPropagation,简称BP)是一种全连接的网络(Full Connect Neural Network),同时也是目前最成功的神经网络学习算法,现实生活中使用神经网络时,大多都是在使用BP算法进行训练。BP神经网络通常适用于:
其中第一类是我们常见的BP神经网络,也是本文所描述的内容。
BP神经网络给人的感觉是:它对数据进行训练的过程其实是一种“循环往复”的感觉,就像游戏开发或者是其他大型软件项目的开发,我们总是先尽可能做出一个成品然后投放到他的适用群体中做基本测试,比如是玩家或者是其他软件适用人群。首次可能叫做内测,然后如此反复还有一测、二测等等,每次测试都会收到用户的各种反馈,这些都会成为关键的改进动力,再修改并作为下次投放的新版本。而这个循环反复的过程正是BP神经网络的思想。
BP神经网络有诸多层,每层之间通过各种边联系在一起构成,然后训练的一次往复分为:
forward就是从输入层到输出层按照一定的权值
中间的隐层可以不止一个,但是每个隐层必须设置一个非线性的激活函数,因为原本隐层的任何一个结点都是通过它的前驱层所有元素加权和得到的,因此,隐层的每个元素求解的总和可以抽象成一个矩阵的乘法。
例如上图的两次层之间的转换(上图没画输出层,后续两个层都是隐层),4个输入的输入层转换到第一个隐层可以写作矩阵的一次变换
而设置激活函数就是为了消除每个隐层之间的线性关联,毕竟像Sigmoid这样的函数绝对是不可能存在线性关系的。
关于backPropagation过程可以类比我上个矩阵分解博客中提到的梯度下降问题,首次预测能得到一个目标数据与实际得到数据的偏差值估计,然后从这个偏差的若干角度(不同权边)去求偏导,得到关于这个偏差值在不同权边的下降梯度。有了下降梯度即可构造出每个权边的迭代式,在第
上述是对于结果的总结, 下面我们用数学的方法细细看下这个过程.
为了方便阐述公式,让我们暂时忘记“哑结点”的设置,让每个网络结点回归最简单的带有阈值的M-P神经元模型:
(请牢牢记住这个图的下标和表示, 下面将以此图来用于证明. 图中
假定现在我们有
关于数据集中某项
由此可以计算出输出层的任意结点
那么这个
梯度下降的原理中, 求偏导的对象是后续需要反复利用机器学习进行拟合的目标. 对于一个forward得到的神经网络无非就是边与结点, 结点值不适合用于求偏导讨论, 因为结点只是一个结果, 无论它是输出层得到的结点还是在隐层得到的结点, 不同之处不过是最终结果与中间结果之分罢了. 算法不可能去拟合存储结果的容器, 而且是去拟合途径, 道路, 工具, 目的去创造模型, 框架. 因此我们选择拟合边权.
边权对于所有数据都是继承的, 言下之意, 第
这也就是生物学习的原理, 我们学习事物并不是机械地在脑中保存这些学习资料原本的内容, 而是建立与这些学习资料有关神经突触, 形成完善的长期记忆.
OK, 知道其理之后下面就来计算. 首先确定了迭代式为
设有函数
通过仔细观察两个迭代式可以再度发现每次边权的更新都存在跨层的使用, 公式17中对于输入层出边
在第
i 层的权边迭代式中需要用到i+1 层的" 惩罚信息 "
显然, 输出层的
(上图所示是forward的流程, 最后一层是输出层, 输入层未给出)最开始每个边权有一个独自的随机值, 通过输入层提供的数据为起点, 逐层根据上图的公式分配数值. 直到分配到最后一层得到数据集
每层进行forward学习都是基于当前的数据
在已知输出层的惩罚信息之后, 逆向走到第一个隐层, 我们可以通过公式16.4求出第一个非输出层的惩罚信息
为什么要设置惩罚信息?为什么每层惩罚信息设置得与结点数一样多?---- 因为这样会让边权计算变得非常方便.
只要按照刚刚思路逐层设置惩罚信息, 那么如上图: 边的更新只用利用边的首端, 末端, 旧权 这三者就OK 就以上图中
当然在实际操作过程中, 我们可以将惩罚信息的更新与边的更新同步进行, 当然拆分来也是可以的.
以上, 就是BP神经网络的全部可用的数学逻辑推导与模型说明. 具体在代码实现过程中某些小地方可能要做些调整, 包括引入动量mobp以及对于" 哑结点 "的处理, 具体我将在代码中细聊. 但是上面的思想就基本能足够描述BP神经网络的forward与backpropagation过程了.
- /**
- * The whole dataset.
- */
- Instances dataset;
-
- /**
- * Number of layers. It is counted according to nodes instead of edges.
- */
- int numLayers;
-
- /**
- * The number of nodes for each layer
- */
- int[] layerNumNodes;
分别是数据集, 以及ANN的层数numLayers以及每一层的结点个数layerNumNodes, 基本的有layerNumNodes.length = numLayers. 这里的层数是包含输入与输出层.
layerNumNodes = [4, 8, 8, 3]一共有四层, 4个结点的输入层(一个数据行有4个条件属性), 3个结点的输出层(有3个标签), 中间两个隐层, 每层都有8个结点
- /**
- * Learning rate.
- */
- public double learningRate;
-
- /**
- * For random number generation.
- */
- Random random = new Random();
-
- /**
- * Momentum coefficient.
- */
- public double mobp;
learningRate即
初始权边需要设置随机值, 用于作为拟合的初始值(并不知道哪个属性最重要, 于是通过反复调整拟合边权, 待拟合的对象在机器学习中都是初始为随机值, 比如Funk-SVD矩阵分解中的矩阵P,Q)
这里引入了很有意思的一个变量mobp, 它表示惯性系数. 每次权值能更新都依赖于权增量
因此得到最后一个隐层的出边权重更新式:
- /**
- ********************
- * The first constructor.
- *
- * @param paraFilename
- * The arff filename.
- * @param paraLayerNumNodes
- * The number of nodes for each layer (may be different).
- * @param paraLearningRate
- * Learning rate.
- * @param paraMobp
- * Momentum coefficient.
- ********************
- */
- public GeneralAnn(String paraFilename, int[] paraLayerNumNodes, double paraLearningRate,
- double paraMobp) {
- // Step 1. Read data.
- try {
- FileReader tempReader = new FileReader(paraFilename);
- dataset = new Instances(tempReader);
- // The last attribute is the decision class.
- dataset.setClassIndex(dataset.numAttributes() - 1);
- tempReader.close();
- } catch (Exception ee) {
- System.out.println("Error occurred while trying to read \'" + paraFilename
- + "\' in GeneralAnn constructor.\r\n" + ee);
- System.exit(0);
- } // Of try
-
- // Step 2. Accept parameters.
- layerNumNodes = paraLayerNumNodes;
- numLayers = layerNumNodes.length;
- // Adjust if necessary.
- layerNumNodes[0] = dataset.numAttributes() - 1;
- layerNumNodes[numLayers - 1] = dataset.numClasses();
- learningRate = paraLearningRate;
- mobp = paraMobp;
- }//Of the first constructor
定义后续正向forward和逆向backPropagation需要的虚拟接口(只要是神经网络, 这两步是必然需要的, 因此作为通用性接口). forward作为一次正向遍历, 输入的数组自然是输入层的结点数组
而 backPropagation 利用
- /**
- ********************
- * Forward prediction.
- *
- * @param paraInput
- * The input data of one instance.
- * @return The data at the output end.
- ********************
- */
- public abstract double[] forward(double[] paraInput);
-
- /**
- ********************
- * Back propagation.
- *
- * @param paraTarget
- * For 3-class data, it is [0, 0, 1], [0, 1, 0] or [1, 0, 0].
- *
- ********************
- */
- public abstract void backPropagation(double[] paraTarget);
. 为了之后构造方便, 这里设计了一个返回最大值下标的小工具
- /**
- ********************
- * Get the index corresponding to the max value of the array.
- *
- * @return the index.
- ********************
- */
- public static int argmax(double[] paraArray) {
- int resultIndex = -1;
- double tempMax = -1e10;
- for (int i = 0; i < paraArray.length; i++) {
- if (tempMax < paraArray[i]) {
- tempMax = paraArray[i];
- resultIndex = i;
- } // Of if
- } // Of for i
-
- return resultIndex;
- }// Of argmax
本文的代码中定义一次训练是对于全体数据集
实际的ANN是函数train( )的多次复合, 每个数据行都反复执行若干forward->backPropagation->forward->backPropagation...的循环, 每个数据行的数据一定不止1次.
- /**
- ********************
- * Train using the dataset.
- ********************
- */
- public void train() {
- double[] tempInput = new double[dataset.numAttributes() - 1];
- double[] tempTarget = new double[dataset.numClasses()];
- for (int i = 0; i < dataset.numInstances(); i++) {
- // Fill the data.
- for (int j = 0; j < tempInput.length; j++) {
- tempInput[j] = dataset.instance(i).value(j);
- } // Of for j
-
- // Fill the class label.
- Arrays.fill(tempTarget, 0);
- tempTarget[(int) dataset.instance(i).classValue()] = 1;
-
- // Train with this instance.
- forward(tempInput);
- backPropagation(tempTarget);
- } // Of for i
- }// Of train
(实际使用时: )
- for (int round = 0; round < 5000; round++) {
- tempNetwork.train();
- } // Of for n
代码中的tempTarget就是本文设置的
- /**
- ********************
- * Test using the dataset.
- *
- * @return The precision.
- ********************
- */
- public double test() {
- double[] tempInput = new double[dataset.numAttributes() - 1];
-
- double tempNumCorrect = 0;
- double[] tempPrediction;
- int tempPredictedClass = -1;
-
- for (int i = 0; i < dataset.numInstances(); i++) {
- // Fill the data.
- for (int j = 0; j < tempInput.length; j++) {
- tempInput[j] = dataset.instance(i).value(j);
- } // Of for j
-
- // Train with this instance.
- tempPrediction = forward(tempInput);
- //System.out.println("prediction: " + Arrays.toString(tempPrediction));
- tempPredictedClass = argmax(tempPrediction);
- if (tempPredictedClass == (int) dataset.instance(i).classValue()) {
- tempNumCorrect++;
- } // Of if
- } // Of for i
-
- System.out.println("Correct: " + tempNumCorrect + " out of " + dataset.numInstances());
-
- return tempNumCorrect / dataset.numInstances();
- }// Of test
执行test()时, 所有边权已经训练完毕, 因此在测试时只需要把每个数据行投放到tempInput[ ](表征
- /**
- * The value of each node that changes during the forward process. The first
- * dimension stands for the layer, and the second stands for the node.
- */
- public double[][] layerNodeValues;
-
- /**
- * The error on each node that changes during the back-propagation process.
- * The first dimension stands for the layer, and the second stands for the
- * node.
- */
- public double[][] layerNodeErrors;
-
- /**
- * The weights of edges. The first dimension stands for the layer, the
- * second stands for the node index of the layer, and the third dimension
- * stands for the node index of the next layer.
- */
- public double[][][] edgeWeights;
-
- /**
- * The change of edge weights. It has the same size as edgeWeights.
- */
- public double[][][] edgeWeightsDelta;
- /**
- ********************
- * The first constructor.
- *
- * @param paraFilename
- * The arff filename.
- * @param paraLayerNumNodes
- * The number of nodes for each layer (may be different).
- * @param paraLearningRate
- * Learning rate.
- * @param paraMobp
- * Momentum coefficient.
- ********************
- */
- public SimpleAnn(String paraFilename, int[] paraLayerNumNodes, double paraLearningRate,
- double paraMobp) {
- super(paraFilename, paraLayerNumNodes, paraLearningRate, paraMobp);
-
- // Step 1. Across layer initialization.
- layerNodeValues = new double[numLayers][];
- layerNodeErrors = new double[numLayers][];
- edgeWeights = new double[numLayers - 1][][];
- edgeWeightsDelta = new double[numLayers - 1][][];
-
- // Step 2. Inner layer initialization.
- for (int l = 0; l < numLayers; l++) {
- layerNodeValues[l] = new double[layerNumNodes[l]];
- layerNodeErrors[l] = new double[layerNumNodes[l]];
-
- // One less layer because each edge crosses two layers.
- if (l + 1 == numLayers) {
- break;
- } // of if
-
- // In layerNumNodes[l] + 1, the last one is reserved for the offset.
- edgeWeights[l] = new double[layerNumNodes[l] + 1][layerNumNodes[l + 1]];
- edgeWeightsDelta[l] = new double[layerNumNodes[l] + 1][layerNumNodes[l + 1]];
- for (int j = 0; j < layerNumNodes[l] + 1; j++) {
- for (int i = 0; i < layerNumNodes[l + 1]; i++) {
- // Initialize weights.
- edgeWeights[l][j][i] = random.nextDouble();
- } // Of for i
- } // Of for j
- } // Of for l
- }// Of the constructor
构造函数的任务就是初始化这四个变量. 这四个变量都只有第1维可以立即确定空间, 后续维度需要在layerNumNodes[ ] 指导下逐一分配.
这里要注意本文代码中对于阈值的处理, 上面讲述数学模型时为了简化, 一律把阈值作为边的末端结点的属性, 这样利于数学公式的说明. 而在实际代码编写时, 我们会仿照1.2中对于 M-P 神经元的简化, 像式5那样将阈值吸收到前导的边之中作为" 哑结点 "的边.
而前导结点中存在一个" 哑结点 ", 这个哑结点在存储中的表示由其功能决定, 可以额外增加一个结点或者额外增加一个边, 自然也可以额外增加一个结点与边, 但是没必要, 因为本身这俩有一方就是1.
就本代码而言, 我们将它扩展到边中了, 而非结点数组之中, 所以第
edgeWeights[l] = new double[layerNumNodes[l] + 1][layerNumNodes[l + 1]];
而非
edgeWeights[l] = new double[layerNumNodes[l]][layerNumNodes[l + 1]];
- /**
- ********************
- * Forward prediction.
- *
- * @param paraInput
- * The input data of one instance.
- * @return The data at the output end.
- ********************
- */
- public double[] forward(double[] paraInput) {
- // Initialize the input layer.
- for (int i = 0; i < layerNodeValues[0].length; i++) {
- layerNodeValues[0][i] = paraInput[i];
- } // Of for i
-
- // Calculate the node values of each layer.
- double z;
- for (int l = 1; l < numLayers; l++) {
- for (int j = 0; j < layerNodeValues[l].length; j++) {
- // Initialize according to the offset, which is always +1
- z = edgeWeights[l - 1][layerNodeValues[l - 1].length][j];
- // Weighted sum on all edges for this node.
- for (int i = 0; i < layerNodeValues[l - 1].length; i++) {
- z += edgeWeights[l - 1][i][j] * layerNodeValues[l - 1][i];
- } // Of for i
-
- // Sigmoid activation.
- // This line should be changed for other activation functions.
- layerNodeValues[l][j] = 1 / (1 + Math.exp(-z));
- } // Of for j
- } // Of for l
-
- return layerNodeValues[numLayers - 1];
- }// Of forward
- /**
- ********************
- * Back propagation and change the edge weights.
- *
- * @param paraTarget
- * For 3-class data, it is [0, 0, 1], [0, 1, 0] or [1, 0, 0].
- ********************
- */
- public void backPropagation(double[] paraTarget) {
- // Step 1. Initialize the output layer error.
- int l = numLayers - 1;
- for (int j = 0; j < layerNodeErrors[l].length; j++) {
- layerNodeErrors[l][j] = layerNodeValues[l][j] * (1 - layerNodeValues[l][j])
- * (paraTarget[j] - layerNodeValues[l][j]);
- } // Of for j
-
- // Step 2. Back-propagation even for l == 0
- while (l > 0) {
- l--;
- // Layer l, for each node.
- for (int j = 0; j < layerNumNodes[l]; j++) {
- double z = 0.0;
- // For each node of the next layer.
- for (int i = 0; i < layerNumNodes[l + 1]; i++) {
- if (l > 0) {
- z += layerNodeErrors[l + 1][i] * edgeWeights[l][j][i];
- } // Of if
-
- // Weight adjusting.
- edgeWeightsDelta[l][j][i] = mobp * edgeWeightsDelta[l][j][i]
- + learningRate * layerNodeErrors[l + 1][i] * layerNodeValues[l][j];
- edgeWeights[l][j][i] += edgeWeightsDelta[l][j][i];
- if (j == layerNumNodes[l] - 1) {
- // Weight adjusting for the offset part.
- edgeWeightsDelta[l][j + 1][i] = mobp * edgeWeightsDelta[l][j + 1][i]
- + learningRate * layerNodeErrors[l + 1][i];
- edgeWeights[l][j + 1][i] += edgeWeightsDelta[l][j + 1][i];
- } // Of if
- } // Of for i
-
- // Record the error according to the differential of Sigmoid.
- // This line should be changed for other activation functions.
- layerNodeErrors[l][j] = layerNodeValues[l][j] * (1 - layerNodeValues[l][j]) * z;
- } // Of for j
- } // Of while
- }// Of backPropagation
- /**
- ********************
- * Test the algorithm.
- ********************
- */
- public static void main(String[] args) {
- int[] tempLayerNodes = { 4, 8, 8, 3 };
- SimpleAnn tempNetwork = new SimpleAnn("D:/Java DataSet/iris.arff", tempLayerNodes, 0.01, 0.6);
-
- for (int round = 0; round < 5000; round++) {
- tempNetwork.train();
- } // Of for n
-
- double tempAccuracy = tempNetwork.test();
- System.out.println("The accuracy is: " + tempAccuracy);
- }// Of main
测试有关数据都体现在main函数中了, 我们建立的是4层BP神经网络, 2个8层的隐层, 数据集是老朋友iris, 超参数下降梯度
非常明显可以观察到, ANN类算法的开销相比于以往我们的算法是高出太多了. 我曾经做过最简单的kNN对于iris的开销最高也只有3.5ms, 主动学习ALEC有51ms左右. 而这里对于150个数据的iris开销就有507ms.
当然, 其实开销大应该也是神经网络类算法普遍的问题. 因此每次进行逐层推荐都伴随大量的带权乘积和, 这两层就构成两层次for循环的叠加, 跟别说外层还有多次训练的嵌套以及数据集本身数据量的循环.
我测试得到的另一个问题就是: 因为初始情况下网络的边权是随机值, 因此得到的结果存在随机性, 于是我将代码执行了多次, 并且统计了每个样例识别度:
可见, 目前这个神经网络模型似乎不是很" 稳定 "的样子, 虽然平时能保持0.97的识别度, 但是某个时刻会突然比较拉胯.
这个不稳定特征似乎能够通过稍微提高隐层的深度或者通过提高训练次数来加强拟合从而提高识别度的稳定性(见下图)
测试到这里似乎并不严谨. 因为将训练集用于测试了, 我们不确定 " 稍微提高隐层的深度或者通过提高训练次数来加强拟合从而提高识别度的稳定性 " 这句话是否受到了过拟合的干扰.
于是为了保险起见, 下面惯例地我将数据集按照7:3 分成训练集和测试再度测试一遍(见下图)
数据不错? 这可能是偶然, 因为分割数据集之后我们的识别率非常不稳定, 这种不稳定在扩大隐层深度(结点)时变得尤为明显, 但是相反, 在削减深度时会有所好转 (见下图)
有意思的地方在于 ---- 当我尝试保持默认数据条件不变的情况下, 不管隐层深度而是去调整训练的次数, 得到的结果又再一次和我预想地不一样 ---- 随着训练次数的增加, 识别率非但没有波动, 反而更加稳定了.(下图)
总结目前的现象:
后续通过对于每个sample进行拆解分析, 我大概找到了原因:(请观察下面这4张图)
因为数据样本量小(只有150, 而且只测试了70%), 再加上边权初始值完全随机, 所以导致了不同样本拟合的标准略有偏差, 达到合格的拟合程度也略有偏差. 例如样本1与样本2的拟合难度要大些, 上限高些, 按照默认的5000次测试并不能保存最终准确度在一个预想的识别度内. 样本1在5000次训练时不到0.45, 但在6250次测试时识别率突然达到0.9以上, 样本2也有类似的情况. 但是样本3的拟合就相对容易, 1200左右测试的时候就能达到1.0的识别度而且相对来说非常稳定. 样本4虽然很快也达到满足要求的拟合目标, 但是这个数据过拟合现象就比较严重, 有明显地因为过拟合导致的识别度下降过程与波动过程.
于是, 我们成功找到了数据波动的原因了! 所谓不稳定就是波动的样本发生了欠拟合. 而缩小隐层深度时可以缩小网络体量, 需要达到的拟合要求有所放宽, 因此更容易去拟合, 不容易欠拟合; 当隐层比较深时, 网络变得更加复杂, 通过拟合达到最高识别率的难度就提升了(见下图), 对于有随机性的拟合中, 欠拟合的概率也就提升了, 体现在本文刚才的图中就是曲线变得波折不断.
实验到最后, 我其实还是没有明显发现过拟合的例子, 只在个别随机边权初始值引导的样例中看见了过拟合的情况. 我初步猜想可能和数据有关系吧, 毕竟我感觉iris数据在BP算法下会因为初始随机值的不一样表现比较大的差异性, 不同随机值影响下, 时而对欠拟合敏感时而对过拟合敏感. 但是需要注意, BP神经网络的过拟合应该是个它的关键问题!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。