目录
介绍
背景
使用程序
图像处理
主窗体
输入层
隐藏层
输出层
网络
实用工具
有待改进
- 下载NeuralNetsFeedForwardArticleSimplified.zip-15 MB
此基本图像分类程序是使用众所周知的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,这近似于随机几率(十分之一)。
在测试过程中,程序将显示每个错误猜中的数字图像。每张图片顶部的标题显示了网络的最佳猜测,后跟一个实数,表示网络对该答案的信心度。这里显示的最后一位数字是5。网络认为它是2,置信度为0.2539(这意味着0.2539是输出层数组中的最大值)。
要训练网络,请单击“训练”按钮。在每一轮推理和反向传播发生时,显示将循环显示数字图像。数字计数器代表数字0-9的训练回合,并且当训练集中的所有数字均已完成时,纪元计数器将递增。由于要更新图形显示,因此进度将非常缓慢(由于程序正在构建内部数据结构,因此第一个10位数字将特别慢)。要使训练更快,请取消选中“显示”和“权重”复选框,并最小化程序UI。
自动监视准确性(每50位左右运行一次测试),并在底部显示定期结果。在几秒钟之内,甚至在第一个纪元完成之前,精度将提高到80%甚至更高。经过几个时期后,显示可能看起来像这样:
这里有几件事值得注意。我们已经完成了7个纪元,或者说是通过训练数字完成了7次旅行(每个集合包含5000多个数字)。我们停止了对纪元7的数字集439的训练(这是第八个纪元,因为它们从0开始编号)。估计的准确性已提高到0.9451,或约94%。这基于测试0-9的50位数字集。如果您增加“测试”字段中的值并运行更长的测试,则准确性可能会上升或下降。权重显示不再是静态的,并且已经开始显示特征性的神秘漩涡和污迹,这些漩涡和污迹代表了隐藏层如何对数字图像进行分类的最佳方法。
如果通过单击“测试”按钮再次对测试数据集运行测试,则结果现在看起来好得多。在测试通过期间,程序使所有的6正确,其中96%的数字,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 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
{
// 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 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));
}
}
输入层
输入层非常简单(为了清楚起见,省略了一些关于实现dropouts的实验代码)。它具有一项功能,该功能将输入层设置为要分类的数字的表示形式。这里要注意的是,来自灰度图像的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();
}
}
}
}
隐藏层神经元的类。为了清楚起见,省略了一些有关dropouts的实验代码。只有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)通常趋于收敛更快,但可能会经历爆炸性的梯度,而其他激活函数可能会消失的梯度。
您训练的每个网络都将具有不同的权重,因为权重是使用随机值初始化的。使用梯度下降训练神经网络受到解决进入“局部极小值”的问题,从而缺少更好的可能解决方案。如果您得到很好的训练结果,则可以使用“文件”菜单上的命令来保存该配重配置,并稍后再读回。
通过旋转代码,可以获得更好的结果。一种可能的改进是随机dropouts。在这种方法中,中间层的一些神经元被随机省略。我已经包含了一些用于实现随机dropouts的代码,但是该代码是实验性的,尚未经过全面测试。UI上不提供此选项,但是通过浏览代码可以进一步进行研究。
另一个没有完全测试的代码问题是动态地降低学习率。我已经在代码中添加了一些挂钩,以减少达到一定次数的尝试后的学习速度。它是相当粗略的实现,并且该功能现在已关闭。应该有可能根据当前的准确性水平而不是受训练的数字数目触发学习率的降低。
还应注意,可能会过度训练网络。更长的训练时间并不总是更好。您可以通过用户界面提前停止训练,但是应该可以通过更改代码以根据定期测试了解到的当前估计准确性来触发早期停止。
最后,MNIST数据的特殊之处在于,训练数据集的数字是由邮政员工绘制的,而测试数据集的数字是由小学生绘制的,因此测试和训练数据之间的质量存在显着差异。通过合并两个数据集,然后将它们任意分为训练数据和测试数据,可能会获得更好的结果。一种方法是将偶数位示例用作训练数据,将奇数位示例用作测试数据。这样可确保您拥有截然不同的训练数据和测试数据,同时减少了两组质量之间的偶然差异。我已经使用“交错”功能实现了类似的功能,该功能尚未经过全面测试。这在用户界面中不可用,但是如果您查看代码,则可以启用它。