65.9K
CodeProject 正在变化。 阅读更多。
Home

手写数字读取器 UI

starIconstarIconstarIconstarIconstarIcon

5.00/5 (16投票s)

2019 年 1 月 7 日

CPOL

5分钟阅读

viewsIcon

40396

downloadIcon

2612

一个 C# 面向对象的神经网络、训练器和 Windows 窗体用户界面,用于识别手写数字。

引言

本文是关于机器学习领域基础知识的另一篇文章的延续。第一部分可能最好先阅读,它在数学上解释了神经网络如何使用 Excel 中一个简单的六神经元网络进行学习。

参见 Excel 中的机器学习1

本源代码演示了如何训练和使用神经网络来解释手写数字。
本文将没有任何数学内容。它全部是关于 C# 对基本机器学习的实现。

背景

人工神经网络 (ANN) 使用 Mnist 手写数字数据集2 进行训练。这是数据科学领域一个经典的问题。它也被称为机器学习的“Hello World”应用程序。Code Project 上已经有一些关于这个主题的演示应用程序,但我认为我的源代码可以帮助到一些人。复杂的问题可能需要不止一种解释,我试图让它尽可能简单。

Using the Code

下载、解压缩并在 Visual Studio 2017 中打开解决方案。

解决方案

解决方案包含五个项目

项目 描述 框架
DeepLearningConsole 训练控制台的入口点 .NET Core 2.2
DeepLearning 主库 .NET Standard 2.0
Data 数据解析器。目前只有 Mnist。 .NET Standard 2.0
MnistTestUi 手动测试的用户界面 .NET Framework 4.7.1
测试 一些单元测试 .NET Core 2.2

为什么这么多框架?

我意识到 .NET Core 的性能比 .NET Framework 快约 30%,而我想为 Windows 窗体应用程序使用 .NET Framework。
不幸的是,您无法从 .NET Framework 引用 .NET Core 库。它们不兼容。
为了解决这个问题,我为通用组件使用了 .NET Standard。

.NET Standard 不是一个框架。它是 .NET API 的正式规范。
所有 .NET 实现都应与其兼容,不仅包括 .NET Core 和 .NET Framework,还包括 Xamarine、Mono、Unity 和 Windows Mobile。
这就是为什么它是可重用组件的良好目标框架选择。

解析 Mnist 数据

这是 Mnist 数据库中的四个文件

  • t10k-images-idx3-ubyte - 测试图像
  • t10k-labels-idx1-ubyte - 测试图像的标签
  • train-images-idx3-ubyte - 训练图像(60000 张)
  • train-labels-idx1-ubyte - 60000 张训练图像的标签

Mnist 中的图像必须从字节数组转换为范围在 01 之间的双精度数组。
文件还包含一些头字段。

private static List<Sample> LoadMnistImages(string imgFileName, string idxFileName, int imgCount)
{
    var imageReader = File.OpenRead(imgFileName);
    var byte4 = new byte[4];
    imageReader.Read(byte4, 0, 4); //magic number
    imageReader.Read(byte4, 0, 4); //magic number
    Array.Reverse(byte4);
    //var imgCount = BitConverter.ToInt32(byte4, 0);

    imageReader.Read(byte4, 0, 4); //width (28)
    imageReader.Read(byte4, 0, 4); //height (28)
    var samples = new Sample[imgCount];

    var labelReader = File.OpenRead(idxFileName);
    labelReader.Read(byte4, 0, 4);//magic number
    labelReader.Read(byte4, 0, 4);//count
    var targets = GetTargets();

    for (int i = 0; i < imgCount; i++)
    {
        samples[i].Data = new double[784];
        var buffer = new byte[784];
        imageReader.Read(buffer, 0, 784);
        for (int b = 0; b < buffer.Length; b++)
            samples[i].Data[b] = buffer[b] / 256d;

        samples[i].Label = labelReader.ReadByte();
        samples[i].Targets = targets[samples[i].Label];
     }
     return samples.ToList();
}

解析过程生成两个训练和测试样本列表。
一个样本由图像像素数组和一个长度为 10 的目标数组组成,该数组包含图像所属的数字信息。
数字零的数组是:1,0,0,0,0,0,0,0,0,0。
数字五是:0,0,0,0,1,0,0,0,0,0(第五个位置为 1),以此类推。

实例化和训练神经网络

要实例化一个新的 ANN,您需要提供其拓扑结构、层数以及每层的神经元数量。
对于 mnist 图像(28x28 像素),输入层必须有 784 个神经元。输出层必须有 10 个。
训练器类使用 TrainData 和指定的学习率来训练神经网络。

var neuralNetwork = new NeuralNetwork(rndSeed: 0, sizes: new[] { 784, 200, 10 });
neuralNetwork.LearnRate = 0.3;
var trainer = new Trainer(neuralNetwork, Mnist.Data);
trainer.Train();

接下来,每个训练样本都被输入到网络中,以便它能够学习。
我发现隐藏层中的 200 个神经元可以使 ANN 训练到 98.5% 的准确率,这似乎足够了。
使用 400 个神经元,准确率达到了 98.8% 的峰值,但训练时间却增加了一倍。

Mnist 训练器

训练器会反复训练 ANN,让它一次“看到”一个训练样本。
所有 60000 张训练图像的一个循环称为一个 epoch。

每个 epoch 之后,ANN 都会被序列化并保存到一个文件中。
然后,ANN 会针对测试样本进行测试,并将结果记录到一个 csv 文件中。
训练图像在每个 epoch 之间也会被打乱。

public void Train(int epochs = 100)
{            
    var rnd = new Random(0);
    var name = $"Sigmoid LR{NeuralNetwork.LearnRate} HL{NeuralNetwork.Layers[1].Count}";
    var csvFile = $"{name}.csv";
    var bestResult = 0d;
    for (int epoch = 1; epoch < epochs; epoch++)
    {
        Shuffle(TrainData.TrainSamples, rnd);
        TrainEpoch();                
        var result = Test();
        Log($"Epoch {epoch} {result.ToString("P")}");
        File.AppendAllText(csvFile, $"{epoch};{result};{NeuralNetwork.TotalError}\r\n");
        if (result > bestResult)
        {
            NeuralNetwork.Save($"{name}.bin");
            Log($"Saved {name}.bin");
            bestResult = result;
        }
    }
 }

以下两个章节的理论已在之前发表的文章中进行描述。

参见 Excel 中的机器学习

前向传播

这通过汇总所有乘以权重的先前神经元的输出来计算每个神经元的权重。
然后,该值通过激活函数进行传递。结果或输出可以从最后一层(也称为输出层)获得。

private void Compute(Sample sample, bool train)
{
    for (int i = 0; i < sample.Data.Length; i++)
        Layers[0][i].Value = sample.Data[i];

    for (int l = 0; l < Layers.Length - 1; l++)
    {
        for (int n = 0; n < Layers[l].Count; n++)
        {
            var neuron = Layers[l][n];
            foreach (var weight in neuron.Weights)
                weight.ConnectedNeuron.Value += weight.Value * neuron.Value;
        }

        var neuronCount = Layers[l + 1].Count;
        if (l + 1 < Layers.Count() - 1)
             neuronCount--; //skipping bias

        for (int n = 0; n < neuronCount; n++)
        {
            var neuron = Layers[l + 1][n];
            neuron.Value = LeakyReLU(neuron.Value / Layers[l].Count);
        }
    }
}

反向传播

该算法调整神经元之间的所有权重。它使网络能够学习并逐步提高其性能。

private void ComputeNextWeights(double[] targets)
{
    var output = OutputLayer;
    for (int t = 0; t < output.Count; t++)
        output[t].Target = targets[t];

    //Output Layer
    foreach (var neuron in output)
    {
        neuron.Error = Math.Pow(neuron.Target - neuron.Value, 2) / 2;
        neuron.Delta = (neuron.Value - neuron.Target) * (neuron.Value > 0 ? 1 : 1 / 20d));
    }
    this.TotalError = output.Sum(n => n.Error);

    foreach (var neuron in Layers[1])
    {
        foreach (var weight in neuron.Weights)
            weight.Delta = neuron.Value * weight.ConnectedNeuron.Delta;
    }
    
    //Hidden Layer
    Parallel.ForEach(Layers[0], GetParallelOptions(), (neuron) => {

        foreach (var weight in neuron.Weights)
        {
            foreach (var connectedWeight in weight.ConnectedNeuron.Weights)
                weight.Delta += connectedWeight.Value * connectedWeight.ConnectedNeuron.Delta;
            var cv = weight.ConnectedNeuron.Value;
            weight.Delta *= (cv > 0 ? 1 : 1 / 20d);
            weight.Delta *= neuron.Value;
        }

    });

    //All deltas are done. Now calculate new weights.
    for (int l = 0; l < Layers.Length - 1; l++)
    {
        var layer = Layers[l];
        foreach (var neuron in layer)
            foreach (var weight in neuron.Weights)
                weight.Value -= (weight.Delta * this.LearnRate);
    }
}

Mnist 测试 UI

测试 UI 用于测试您自己的手写。它有两个面板。小面板解释单个绘制的数字,底部的大面板允许您绘制一个数字。

图像预处理

Mnist 数据库主页称:

“NIST 的原始黑白(二值)图像经过尺寸归一化,使其适合 20x20 像素的框,同时保留其纵横比。生成的图像包含灰度,这是由于归一化算法使用的抗锯齿技术造成的。通过计算像素的质心,将图像居中在 28x28 的图像中,并通过将图像移动到使该点位于 28x28 字段的中心。”

以下是使用位图和 Windows 窗体图形实现此操作的说明。

首先,找到围绕绘制数字的最小正方形。

public Rectangle DrawnSquare()
{
    var fromX = int.MaxValue;
    var toX = int.MinValue;
    var fromY = int.MaxValue;
    var toY = int.MinValue;
    var empty = true;
    for (int y = 0; y < Bitmap.Height; y++)
    {
        for (int x = 0; x < Bitmap.Width; x++)
        {
            var pixel = Bitmap.GetPixel(x, y);
            if (pixel.A > 0)
            {
                empty = false;
                if (x < fromX)
                    fromX = x;
                if (x > toX)
                    toX = x;
                if (y < fromY)
                    fromY = y;
                if (y > toY)
                    toY = y;
            }
        }
    }
    if (empty)
        return Rectangle.Empty;
    var dx = toX - fromX;
    var dy = toY - fromY;
    var side = Math.Max(dx, dy);
    if (dy > dx)
        fromX -= (side - dx) / 2;
    else
        fromY -= (side - dy)/ 2;

    return new Rectangle(fromX, fromY, side, side);
}

裁剪出正方形并将其调整为 20x20 的新位图。

public DirectBitmap CropToSize(Rectangle drawnRect, int width, int height)
{
    var bmp = new DirectBitmap(width, height);
    bmp.Bitmap.SetResolution(Bitmap.HorizontalResolution, Bitmap.VerticalResolution);

    var gfx = Graphics.FromImage(bmp.Bitmap);
    gfx.CompositingQuality = CompositingQuality.HighQuality;
    gfx.InterpolationMode = InterpolationMode.HighQualityBicubic;
    gfx.PixelOffsetMode = PixelOffsetMode.HighQuality;
    gfx.SmoothingMode = SmoothingMode.AntiAlias;
    var rect = new Rectangle(0, 0, width, height);
    gfx.DrawImage(Bitmap, rect, drawnRect, GraphicsUnit.Pixel);
    return bmp;
}

最后,将 20x20 的图像绘制,使其质心居中在 28x28 的位图中。

public Point GetMassCenterOffset()
{
    var path = new List<Vector2>();
    for (int y = 0; y < Height; y++)
    {
        for (int x = 0; x < Width; x++)
        {
            var c = GetPixel(x, y);
            if (c.A > 0)
                path.Add(new Vector2(x, y));
        }
    }
    var centroid = path.Aggregate(Vector2.Zero, (current, point) => current + point) / path.Count();
    return new Point((int)centroid.X - Width / 2, (int)centroid.Y - Height / 2);
}

protected DirectBitmap PadAndCenterImage(DirectBitmap bitmap)
{
    var drawnRect = bitmap.DrawnRectangle();
    if (drawnRect == Rectangle.Empty)
        return null;

    var bmp2020 = bitmap.CropToSize(drawnRect, 20, 20);

    //Make image larger and center on center of mass
    var off = bmp2020.GetMassCenterOffset();
    var bmp2828 = new DirectBitmap(28, 28);
    var gfx2828 = Graphics.FromImage(bmp2828.Bitmap);
    gfx2828.DrawImage(bmp2020.Bitmap, 4 - off.X, 4 - off.Y);

    bmp2020.Dispose();
    return bmp2828;
}

然后,只需从图像中提取字节,并用它们来查询 ANN。

public byte[] ToByteArray()
{
    var bytes = new List<byte>();
    for (int y = 0; y < Bitmap.Height; y++)
    {
        for (int x = 0; x < Bitmap.Width; x++)
        {
            var color = Bitmap.GetPixel(x, y);
            var i = color.A;
            bytes.Add(i);
        }
     }
     return bytes.ToArray();
}

如果您对 Mnist 图像的外观感到好奇,UI 还有一个显示 Mnist 图像的功能。但我不打算详细介绍 UI 的每个细节,因为我觉得我们偏离主题了。

最后

希望您喜欢我的文本,并学到了一些您不知道的知识。如果您有任何问题、评论或想法,请在下方留言。

目前就到这里,别忘了投票。祝您愉快!

链接

  1. Excel 中的机器学习 - Kristian Ekman
  2. Mnist 手写数字数据集 - Yann LeCun, Corinna Cortes, Christopher J.C. Burges

历史

  • 2019 年 1 月 7 日 - 版本 1.0
© . All rights reserved.