C#中的MNIST数字识别
一个简单分类问题的C#实现(从MNIST数据集中识别数字)。通过这样的练习,可以深入了解如何使用反向传播实现梯度下降。当在TensorFlow等专业库中设计解决方案时,这些见解将被证明是有价值的。
介绍
此基本图像分类程序是使用众所周知的MNIST数据集进行数字识别的示例。这是用于机器学习的“ hello world”程序。目的是提供一个示例,程序员可以编写代码并逐步入门,以深入了解反向传播的实际工作原理。TensorFlow之类的框架通过利用GPU的功能来解决更复杂的处理器密集型问题,但是当新手不知其网络为何无法产生预期效果时,它可能会成为“泄漏抽象”结果。揭开这个主题的神秘面纱并证明不需要简单的硬件,语言或软件库即可实现针对简单问题的机器学习算法,这可能会很有用。
背景
这里是一些有用文章的链接,这些文章提供了神经网络的理论框架:
http://neuralnetworksanddeeplearning.com/chap1.html
https://ml4a.github.io/ml4a/neural_networks/
https://web.archive.org/web/20150317210621/https://www4.rgu.ac.uk/files/chapter3%20-%20bp.pdf
这是我们打算构建的神经网络的示意图:
我们正在制作一个具有一层隐藏层的前馈神经网络。我们的网络将在输入层中包含784个像元,每个像素对应一个28x28黑白数字图像。隐藏层中的单元数是可变的。输出层将包含10个像元,每个数字0-9对应一个。该输出层有时称为单热向量。训练目标是,为了成功进行推理,与正确数字相对应的单元格将包含接近1的值,而其余单元格则包含接近零的值。
使用程序
在Visual Studio中构建解决方案。该示例是Winforms桌面程序。
您可以通过运行一些测试来验证此随机加权网络没有预测价值。单击测试按钮,使其运行几秒钟,然后再次单击以停止。每个数字的结果表示正确预测的分数。值1.0表示所有设置都是正确的,而值0.0表示没有设置正确。该显示表明,很少有网络的猜测是正确的。如果您进行了更长的测试系列,则值将更统一,并且每个数字接近0.1,这近似于随机几率(十分之一)。
在测试过程中,程序将显示每个错误猜中的数字图像。每张图片顶部的标题显示了网络的最佳猜测,后跟一个实数,表示网络对该答案的信心度。此处显示的最后一位数字为5。网络认为该数字为2,置信度为0.2539(这意味着0.2539是输出层阵列中的最大值)。
要训练网络,请单击“训练”按钮。在每一轮推断和反向传播发生时,显示将循环显示数字图像。Digit计数器代表数字0-9的训练回合,并且训练集中的所有数字都已完成时,Epoch计数器将递增。由于要更新图形显示,因此进度将非常缓慢(由于程序正在构建内部数据结构,因此第一个10位数字将特别慢)。为了使训练更快,请取消选中“显示”和“权重”复选框,并最小化程序UI。
自动监视准确性(每50位左右运行一次测试),并在底部显示定期结果。在几秒钟之内,甚至在第一个纪元完成之前,精度将提高到80%甚至更高。经过几个时期后,显示可能看起来像这样:
这里有几件事值得注意。我们已经完成了7个纪元,或者说是通过训练数字完成了7次旅行(每个集合包含5000多个数字)。我们停止了对纪元7的数字集439的训练(这是第八个纪元,因为它们从0开始编号)。估计的准确性已提高到0.9451,或约94%。这基于测试0-9的50位数字集。如果您增加“测试”字段中的值并运行更长的测试,则准确性可能会上升或下降。重量显示不再是静态的,并且已经开始显示特征性的神秘漩涡和污迹,这些漩涡和污迹代表了隐藏层如何对数字图像进行分类的最佳思路。
如果通过单击“测试”按钮再次对测试数据集运行测试,则结果现在看起来好得多。在测试通过期间,程序使所有的6正确,其中96%的数字,3、4、7和9,以及98%的8。法孚的表现很差,只有86%。
图像处理
MNIST数据集可以以各种格式在线获得,但是我选择使用训练集的jpeg图像。程序读取这些图像并提取像素信息。这是将像素值复制到字节数组的代码段。位图数据被锁定,并且InteropServices用于访问非托管代码。这是获取每个像素值的最有效,最快捷的方法。
public static byte[] ByteArrayFromImage(Bitmap bmp) { Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height); BitmapData data = bmp.LockBits(rect, ImageLockMode.ReadOnly, bmp.PixelFormat); IntPtr ptr = data.Scan0; int numBytes = data.Stride * bmp.Height; byte[] image_bytes = new byte[numBytes]; System.Runtime.InteropServices.Marshal.Copy(ptr, image_bytes, 0, numBytes); bmp.UnlockBits(data); return image_bytes; }
从那里开始,信息被安排成结构,使其有可能随机访问训练和测试数据集的每个图像中的每个像素。每个原始图像都显示成网格排列的数千个数字。由于各个数字具有一致的尺寸,因此可以隔离每个数字并将其存储在DigitImage类的实例中。数字像素存储为字节数组列表,这些字节数组代表每个单独数字的扫描线。这是在DigitImages类库中完成的。
namespace digitImages{ public class DigitImage { public static int digitWidth = 28; public static int nTypes = 10; static Random random = new Random(DateTime.Now.Millisecond + DateTime.Now.Second); public static DigitImage[] trainingImages = new DigitImage[10]; public static DigitImage[] testImages = new DigitImage[10]; public Bitmap image = null; public byte[] imageBytes; List pixelRows = null;... public static void loadPixelRows(int Expected, bool testing) { if (testing) { byte[] imageBytes = DigitImage.testImages[Expected].imageBytes; Bitmap image = DigitImage.testImages[Expected].image; if (DigitImage.testImages[Expected].pixelRows == null) { DigitImage.testImages[Expected].pixelRows = new List(); for (int i = 0; i < image.Height; i++) { int index = i * image.Width; byte[] rowBytes = new byte[image.Width]; for (int w = 0; w < image.Width; w++) { rowBytes[w] = imageBytes[index + w]; } DigitImage.testImages[Expected].pixelRows.Add(rowBytes); } } } else { byte[] imageBytes = DigitImage.trainingImages[Expected].imageBytes; Bitmap image = DigitImage.trainingImages[Expected].image; if (DigitImage.trainingImages[Expected].pixelRows == null) { DigitImage.trainingImages[Expected].pixelRows = new List(); for (int i = 0; i < image.Height; i++) { int index = i * image.Width; byte[] rowBytes = new byte[image.Width]; for (int w = 0; w < image.Width; w++) { rowBytes[w] = imageBytes[index + w]; } DigitImage.trainingImages[Expected].pixelRows.Add(rowBytes); } } } }
主要形式
主窗体使用非闪烁面板绘制重量显示。
public class MyPanel : System.Windows.Forms.Panel{ public MyPanel() { this.SetStyle( System.Windows.Forms.ControlStyles.UserPaint | System.Windows.Forms.ControlStyles.AllPaintingInWmPaint | System.Windows.Forms.ControlStyles.OptimizedDoubleBuffer, true); } }
这是程序如何将784输入的灰度表示绘制为隐藏层权重,以及将10个隐藏层绘制为输出层权重的方法:
private void rangeOfHiddenWeights(out double MIN, out double MAX){ List all = new List(); for (int N = 0; N < NeuralNetFeedForward.hiddenLayer.nNeurons; N++) { foreach (double d in NeuralNetFeedForward.hiddenLayer.neurons[N].weights) { all.Add(d); } } all.Sort(); MIN = all[0]; MAX = all[all.Count - 1];}private void drawWeights(Graphics ee, HiddenLayer hidLayer){ int weightPixelWidth = 4; int W = (digitImages.DigitImage.digitWidth * (weightPixelWidth + 1)); int H = (digitImages.DigitImage.digitWidth * (weightPixelWidth)); int wid = H / 10; int yOffset = 0; int xOffset = 0; bool drawRanges = false; double mind = 0; double maxd = 0; rangeOfHiddenWeights(out mind, out maxd); double range = maxd - mind; Bitmap bmp = new Bitmap(W, H + wid); Graphics e = Graphics.FromImage(bmp); for (int N = 0; N < hidLayer.nNeurons; N++) { double[] weights = hidLayer.neurons[N].weights; // draw hidden to output neuron weights for this hidden neuron double maxout = double.MinValue; // max weight of last hidden layer to output layer double minout = double.MaxValue; // min weight of last hidden layer to output layer for (int output = 0; output < NeuralNetFeedForward.outputLayer.nNeurons; output++) { if (N < NeuralNetFeedForward.outputLayer.neurons[output].weights.Length) { double Weight = NeuralNetFeedForward.outputLayer.neurons[output].weights[N]; if (Weight > maxout) { maxout = Weight; } if (Weight < minout) { minout = Weight; } double Mind = NeuralNetFeedForward.outputLayer.neurons[output].weights.Min(); double Maxd = NeuralNetFeedForward.outputLayer.neurons[output].weights.Max(); double Range = Maxd - Mind; double R = ((Weight - Mind) * 255.0) / Range; int r = Math.Min(255, Math.Max(0, (int)R)); Color color = Color.FromArgb(r, r, r); int X = (wid * output); int Y = H; e.FillRectangle(new SolidBrush(color), X, Y, wid, wid); } else { maxout = 0; minout = 0; } } // draw the input to hidden layer weights for this neuron for (int column = 0; column < digitImages.DigitImage.digitWidth; column++) { for (int row = 0; row < digitImages.DigitImage.digitWidth; row++) { double weight = weights[(digitImages.DigitImage.digitWidth * row) + column]; double R = ((weight - mind) * 255.0) / range; int r = Math.Min(255, Math.Max(0, (int)R)); int x = column * weightPixelWidth; int y = row * weightPixelWidth; int r2 = r; if (hidLayer.neurons[N].isDropped) { r2 = Math.Min(255, r + 50); } Color color = Color.FromArgb(r, r2, r); e.FillRectangle(new SolidBrush(color), x, y, weightPixelWidth, weightPixelWidth); } } xOffset += W; if (xOffset + W >= this.ClientRectangle.Width) { xOffset = 0; yOffset += W; } ee.DrawImage(bmp, new Point(xOffset, yOffset)); }}
输入层
输入层非常简单(为清楚起见,省略了一些有关实现辍学的实验代码)。它具有一项功能,该功能将输入层设置为要分类的数字的表示形式。这里要注意的是,来自灰度图像的0-255字节信息被压缩为0到1之间的实数。
using System;using System.Collections.Generic;using System.Linq;using System.Text;namespace NeuralNetFeedForward{ class InputLayer { public double[] inputs; public void setInputs(byte[] newInputs) { inputs = new double[newInputs.Length]; for (int i = 0; i < newInputs.Length; i++) { // squash input inputs[i] = (double)newInputs[i] / 255.0; } } }}
隐藏层
隐藏层同样非常简单。它具有两个功能,激活(推断)和反向传播(学习)。
class HiddenLayer { // public static int nNeurons = 15; // original public int nNeurons = 30; public HiddenNeuron[] neurons; public NeuralNetFeedForward network; public HiddenLayer(int N, NeuralNetFeedForward NETWORK) { network = NETWORK; nNeurons = N; neurons = new HiddenNeuron[nNeurons]; for (int i = 0; i < neurons.Length; i++) { neurons[i] = new HiddenNeuron(i, this); } } public void backPropagate() { foreach (HiddenNeuron n in neurons) { n.backPropagate(); } } public void activate() { foreach (HiddenNeuron n in neurons) { n.activate(); } } }}
隐藏层神经元的类。为了清楚起见,省略了一些有关辍学的实验代码。只有3个函数:activate,backPropagate和initWeights,它们将权重初始化为随机值。请注意,在向后传递时,必须根据该神经元对该误差的贡献来计算每个输出的误差。由于输出层权重在向后传递时发生变化,因此必须保存先前的正向传递权重并用于计算误差。这里的另一个难题是程序允许使用四个不同的激活功能。稍后再详细介绍。
class HiddenNeuron{ public int index = 0; public double ERROR = 0; public double[] weights = new double[digitImages.DigitImage.digitWidth * digitImages.DigitImage.digitWidth]; public double[] oldWeights = new double[digitImages.DigitImage.digitWidth * digitImages.DigitImage.digitWidth]; public double sum = 0.0; public double sigmoidOfSum = 0.0; public HiddenLayer layer; public HiddenNeuron(int INDEX, HiddenLayer LAYER) { layer = LAYER; index = INDEX; initWeights(); } public void initWeights() { weights = new double[digitImages.DigitImage.digitWidth * digitImages.DigitImage.digitWidth]; for (int y = 0; y < weights.Length; y++) { weights[y] = NeuralNetFeedForward.randomWeight(); } } public void activate() { sum = 0.0; for (int y = 0; y < weights.Length; y++) { sum += NeuralNetFeedForward.inputLayer.inputs[y] * weights[y]; } sigmoidOfSum = squashFunctions.Utils.squash(sum); } public void backPropagate() { // see example: // https://web.archive.org/web/20150317210621/https://www4.rgu.ac.uk/files/chapter3%20-%20bp.pdf double sumError = 0.0; foreach (OutputNeuron o in NeuralNetFeedForward.outputLayer.neurons) { sumError += ( o.ERROR * o.oldWeights[index]); } ERROR = squashFunctions.Utils.derivative(sigmoidOfSum) * sumError; for (int w = 0; w < weights.Length; w++) { weights[w] += (ERROR * NeuralNetFeedForward.inputLayer.inputs[w]) * layer.network.learningRate; } }}
输出层
输出层和神经元的非常简单的代码。请注意,输出层神经元的数量始终为10。这与对数字进行分类时可能的答案0-9的数量相对应。如前所述,在反向传播期间,有必要在调整权重之前保留先前(旧)权重的副本,因此可以将它们用于中间(隐藏)层的误差计算中。与隐藏层一样,权重被初始化为随机值。
class OutputLayer{ public NeuralNetFeedForward network; public HiddenLayer hiddenLayer; public int nNeurons = 10; public OutputNeuron[] neurons; public OutputLayer(HiddenLayer h, NeuralNetFeedForward NETWORK) { network = NETWORK; hiddenLayer = h; neurons = new OutputNeuron[nNeurons]; for (int i = 0; i < nNeurons; i++) { neurons[i] = new OutputNeuron(this); } } public void activate() { foreach (OutputNeuron n in neurons) { n.activate(); } } public void backPropagate() { foreach (OutputNeuron n in neurons) { n.backPropagate(); } }}class OutputNeuron{ public OutputLayer outputLayer; public double sum = 0.0; public double sigmoidOfSum = 0.0; public double ERROR = 0.0; public double [] weights; public double[] oldWeights; public double expectedValue = 0.0; public OutputNeuron(OutputLayer oL) { outputLayer = oL; weights = new double[outputLayer.hiddenLayer.nNeurons]; oldWeights = new double[outputLayer.hiddenLayer.nNeurons]; initWeights(); } public void activate() { sum = 0.0; for (int y = 0; y < weights.Length; y++) { sum += outputLayer.hiddenLayer.neurons[y].sigmoidOfSum * weights[y]; } sigmoidOfSum = squashFunctions.Utils.squash(sum); } public void calculateError() { ERROR = squashFunctions.Utils.derivative(sigmoidOfSum) * (expectedValue - sigmoidOfSum); } public void backPropagate() { // see example: // https://web.archive.org/web/20150317210621/https://www4.rgu.ac.uk/files/chapter3%20-%20bp.pdf calculateError(); int i = 0; foreach (HiddenNeuron n in outputLayer.hiddenLayer.neurons) { oldWeights[i] = weights[i]; // to be used for hidden layer back propagation weights[i] += (ERROR * n.sigmoidOfSum) * outputLayer.network.learningRate; i++; } } public void initWeights() { for (int y = 0; y < weights.Length; y++) { weights[y] = NeuralNetFeedForward.randomWeight(); } }}
网络
实现网络的类。为了清楚起见,已省略了一些有关将权重保存到文件,动态降低学习率和其他非必要功能的代码。重要函数是train(),testInference(),setExpected()和answer()。请注意,setExpected()的工作方式是将与所需答案对应的输出神经元的值设置为1,而将所有其他输出神经元的值设置为0。这种编码信息的方法有时称为“单热向量”。同样,answer()通过查找具有最高值的输出神经元来工作。理想情况下,该值应接近1,其他所有值都应接近0。这是训练目标,通过该目标可以计算错误并通过网络进行反向传播。train()函数只需调用setExpected()即可建立目标,然后调用activate()和backPropagate()尝试进行推断,并根据计算出的误差调整图层权重。activate()和backPropagate()函数仅调用中间层和输出层的相应函数。
class NeuralNetFeedForward{ // settings public enum ActivationType { SIGMOID, TANH, RELU, LEAKYRELU }; public ActivationType activationType = ActivationType.LEAKYRELU; public double learningRate = 0.01; public List errors = new List(); public static int expected; public static Random rand = new Random(DateTime.Now.Millisecond); public static InputLayer inputLayer; public static HiddenLayer hiddenLayer; public static OutputLayer outputLayer; public int nFirstHiddenLayerNeurons = 30; public void setExpected(int EXPECTED) { expected = EXPECTED; for (int i = 0; i < NeuralNetFeedForward.outputLayer.neurons.Length; i++) { if (i == EXPECTED) { NeuralNetFeedForward.outputLayer.neurons[i].expectedValue = 1.0; } else { NeuralNetFeedForward.outputLayer.neurons[i].expectedValue = 0.0; } } } public void create() { inputLayer = new InputLayer(); hiddenLayer = new HiddenLayer(nFirstHiddenLayerNeurons, this); outputLayer = new OutputLayer(hiddenLayer, this); } public NeuralNetFeedForward(int NNeurons) { nFirstHiddenLayerNeurons = NNeurons; create(); } public NeuralNetFeedForward() { create(); } public static double dotProduct(double[] a, double[] b) { double result = 0.0; for (int i = 0; i < a.Length; i++) { result += a[i] * b[i]; } return result; } public static double randomWeight() { double span = 50000; int spanInt = (int)span; double magnitude = 10.0; return ((double)(NeuralNetFeedForward.rand.Next(0, spanInt * 2) - spanInt)) / (span * magnitude); } public bool testInference(int EXPECTED, out int guessed, out double confidence) { setExpected(EXPECTED); activate(); guessed = answer(out confidence); return (guessed == EXPECTED); } public void train(int nIterations, int EXPECTED) { setExpected(EXPECTED); for (int n = 0; n < nIterations; n++) { activate(); backPropagate(); } } public void activate() { hiddenLayer.activate(); outputLayer.activate(); } public void backPropagate() { outputLayer.backPropagate(); hiddenLayer.backPropagate(); } public int answer(out double confidence) { confidence = 0.0; double max = 0; int result = -1; for (int n = 0; n < NeuralNetFeedForward.outputLayer.nNeurons; n++) { double s = NeuralNetFeedForward.outputLayer.neurons[n].sigmoidOfSum; if (s > max) { confidence = s; result = n; max = s; } } return result; }}
实用工具
您可以选择以下四个激活函数来运行该程序:S形,双曲正切,线性校正和“泄漏”线性校正。squashFunctions类库中的Utils类包含这四个激活函数的实现,以及它们在反向传播中使用的相应派生类。
public class Utils{ public enum ActivationType { SIGMOID, TANH, RELU, LEAKYRELU }; public static ActivationType activationType = ActivationType.LEAKYRELU; public static double squash(double x) { if (activationType == ActivationType.TANH) { return hyTan(x); } else if (activationType == ActivationType.RELU) { return Math.Max(x, 0); } else if (activationType == ActivationType.LEAKYRELU) { if (x >= 0) { return x; } else { return x * 0.15; } } else { return sigmoid(x); } } public static double derivative(double x) { if (activationType == ActivationType.TANH) { return derivativeOfTanHofX(x); } else if (activationType == ActivationType.RELU) { return x > 0 ? 1 : 0; } else if (activationType == ActivationType.LEAKYRELU) { return x >= 0 ? 1 : 0.15; } else { return derivativeOfSigmoidOfX(x); } } public static double sigmoid(double x) { double s = 1.0 / (1.0 + Math.Exp(-x)); return s; } public static double derivativeOfSigmoid(double x) { double s = sigmoid(x); double sPrime = s * (1.0 - s); return sPrime; } public static double derivativeOfSigmoidOfX(double sigMoidOfX) { double sPrime = sigMoidOfX * (1.0 - sigMoidOfX); return sPrime; } public static double hyTan(double x) { double result = Math.Tanh(x); return result; } public static double derivativeOfTanH(double x) { double h = hyTan(x); return derivativeOfTanHofX(h); } public static double derivativeOfTanHofX(double tanHofX) { return 1.0 - (tanHofX * tanHofX); } public static double dotProduct(double[] a, double[] b) { double result = 0.0; for (int i = 0; i < a.Length; i++) { result += a[i] * b[i]; } return result; }}
有待改进
仅需几分钟的培训,该网络就可以达到大约95%的准确性。这足以证明这一概念,但实际上并不是一个很好的实际结果。有几种可能的方法可以提高准确性。
首先,您可以尝试改变隐藏神经元的数量。所包含的代码默认情况下使用20个神经元,但是可以增加或减少此数目(硬编码最少10个隐藏层神经元,但是您可以根据需要更改此数目)。此外,您可以尝试从UI的下拉菜单中选择一种激活功能,尝试使用不同的激活功能。一些激活函数(例如RELU)通常趋于收敛更快,但可能会经历爆炸梯度,而其他激活函数可能会消失梯度。
您训练的每个网络都将具有不同的权重,因为权重是使用随机值初始化的。使用梯度下降的神经网络训练受制于“局部极小值”的问题,因此缺少更好的解决方案。如果您得到很好的训练结果,则可以使用“文件”菜单上的命令保存该重量配置,并在以后读回。
通过旋转代码,可以获得更好的结果。一种可能的改进是随机辍学。在这种方法中,中间层的一些神经元被随机省略。我已经包含了一些用于实现随机辍学的代码,但是该代码是实验性的,尚未经过全面测试。UI上不提供此选项,但是通过浏览代码可以进一步进行研究。
尚未经过充分测试的另一个代码皱纹正在动态降低学习率。我已经在代码中添加了一些挂钩,以减少达到一定次数的尝试后的学习速度。它是相当粗略的实现,并且此功能现在已关闭。应该有可能根据当前的准确性水平而不是受训练的数字数目来触发学习率的降低。
还应注意,可能会过度训练网络。更长的培训时间并不总是更好。您可以通过用户界面提前停止训练,但是应该可以通过更改代码以根据定期测试了解到的当前估计准确性来触发早期停止。