C# 中的 3 层前馈神经网络,带图形显示






4.96/5 (20投票s)
C# 中的 MNIST 数字识别
引言
这个基本的图像分类程序是一个使用著名的 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 的值,而其余单元包含接近零的值。
使用程序
您可以使用附带的 zip 文件中的代码在 Visual Studio 中构建解决方案。该示例是一个 Winforms 桌面程序。
显示屏显示 MNIST 训练数字的图像,上面覆盖着隐藏层神经元的表示。每个神经元连接到输入层的权重表示为灰度像素。这些连接对应于每个手写数字图像中的像素数量。它们被初始化为随机值,这就是它们看起来像静态的原因。每个块底部的灰色方块行表示每个隐藏层神经元与 10 个输出单元之间连接的权重值。
您可以通过运行一些测试来验证这个随机加权网络没有预测价值。单击测试按钮并让它运行几秒钟,然后再次单击以停止。每个数字的结果表示正确预测的比例。1.0 的值表示全部正确,而 0.0 的值表示全部不正确。此显示表明网络很少有正确猜测。如果您运行一系列更长的测试,这些值将更均匀,每个数字都更接近 0.1,这近似于随机机会(10 分之 1)。
在测试过程中,程序会显示每个错误猜测的数字图像。每张图像顶部的标题显示网络的最佳猜测,后跟一个实数,表示网络对该答案的置信度。此处显示的最后一个数字是 5。网络认为它是 2,置信度为 0.2539(这意味着 0.2539 是输出层数组中的最大值)。
要训练网络,请单击“训练”按钮。随着每一轮推断和反向传播的进行,显示屏将循环显示数字图像。数字计数器表示 0-9 数字的训练轮次,当训练集中的所有数字都完成后,周期计数器会递增。由于更新图形显示的工作,进度会非常缓慢(第一组 10 个数字会特别慢,因为程序正在构建内部数据结构)。要使训练速度更快,请取消选中“显示”和“权重”复选框,并最小化程序 UI。
准确性会自动监控(大约每 50 个数字运行一轮测试),并在底部显示周期性结果。在几秒钟内,准确性将上升到 80% 甚至更高,甚至在第一个周期完成之前,这大约需要一分钟。经过几个周期后,显示屏可能看起来像这样
这里有几点值得注意。我们已经完成了 7 个周期,或者说对训练数字进行了 7 次遍历(每组包含超过 5000 个数字)。我们在第 7 个周期的第 439 个数字集(因为它们从 0 开始编号,所以是第八个周期)上停止了训练。估计的准确性已提高到 0.9451,即大约 94%。这是基于对 50 个 0-9 数字集进行测试的结果。如果您增加“测试
”字段中的值并运行更长的测试,准确性可能会上升或下降。权重显示不再是静态的,并已开始显示代表隐藏层对数字图像进行分类的最佳想法的特征性神秘漩涡和污迹。
如果您再次单击“测试”按钮对测试数据集进行测试,结果现在看起来好多了。在此测试过程中,程序正确识别了所有 6,96% 的 1、3、4、7 和 9,以及 98% 的 8。5 的表现非常差,只有 86%。
图像处理
MNIST 数据集
在线提供各种格式,但我选择使用训练集的 jpeg 图像。图像文件包含在解决方案 zip 文件中。它们位于 FeedForward/bin/Debug 中。程序读取这些图像并提取像素信息。这是将像素值复制到字节数组的代码片段。位图数据被锁定,并使用 InteropServices 访问非托管代码。这是获取每个像素值最有效和最快的方法。
//
// DigitImage.cs
//
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<byte[]> 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<byte[]>();
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<byte[]>();
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
{
// non-flicker drawing 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<double> all = new List<double>();
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));
}
}
输入层
输入层非常简单(为了清晰起见,省略了一些关于实现 dropout 的实验代码)。它有一个函数,用于将输入层设置为要分类数字的表示。这里需要注意的是,来自灰度图像的 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;
}
}
}
}
隐藏层
隐藏层同样非常简单。它有两个函数,`activate`(推断)和 `backPropagate`(学习)。
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();
}
}
}
}
隐藏层神经元的类。为清晰起见,省略了一些关于 dropout 的实验代码。只有三个函数:`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<inferenceError> errors = new List<inferenceError>();
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;
}
}
实用程序
您可以选择四种激活函数来运行此程序:Sigmoid、双曲正切、修正线性单元和“泄露”修正线性单元。`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)通常倾向于更快收敛,但可能会受到梯度爆炸的影响,而另一些则可能受到梯度消失的影响。
您训练的每个网络都会得到不同的权重,因为权重是用随机值初始化的。使用梯度下降训练神经网络会遇到陷入“局部最小值”的问题,从而错过更好的可能解决方案。如果您得到了一个好的训练结果,您可以使用“文件”菜单上的命令来保存该权重配置并稍后读取它。
通过调整代码,可以获得更好的结果。一个可能的改进是随机 dropout。在这种方法中,中间层的一些神经元被随机省略。我已包含一些用于实现随机 dropout 的代码,但这些代码是实验性的,尚未经过充分测试。此选项无法从 UI 中获得,但通过摆弄代码,您可以进一步研究。
另一个尚未充分测试的代码细节是动态降低学习率。我已在代码中包含了一些钩子,以便在达到一定数量的试验后降低学习率。它的实现相当粗糙,并且目前该功能已关闭。应该可以根据当前的准确度水平而不是训练的数字数量来触发学习率的降低。
还应该指出的是,网络可能会过拟合。更长的训练时间并非总是更好。您可以通过用户界面提前停止训练,但通过修改代码,应该可以根据周期性测试学习到的当前估计准确度来触发提前停止。
最后,MNIST 数据的一个特点是训练数据集的数字由邮政员工绘制,而测试数据集的数字由学童绘制,因此测试数据和训练数据之间存在显著的质量差异。通过将两个数据集合并,然后任意将它们分为训练数据和测试数据,可能会获得更好的结果。一种方法是使用偶数位的数字示例作为训练数据,使用奇数位的数字示例作为测试数据。这确保了您拥有不同的训练数据和测试数据,同时减少了两组数据质量之间的偶然差异。我已通过“交错”功能实现了类似的功能,但尚未经过充分测试。这在 UI 中不可用,但如果您查看代码,可以启用它。
历史
- 2020年6月26日:初始版本
- 2020年9月15日:更新