机器学习是时下非常流行的话题,而Tensorflow是机器学习中最有名的工具包。TensorflowSharp是Tensorflow的C#语言表述。本文会对TensorflowSharp的使用进行一个简单的介绍。
本文会先介绍Tensorflow的一些基本概念,然后实现一些基本操作例如数字相加等运算。然后,实现求两个点(x1,y1)和(x2,y2)的距离。最后,通过这些前置基础和一些C#代码,实现使用KNN方法识别MNIST手写数字集合(前半部分)。阅读本文绝对不需要任何机器学习基础,因为我现在也才刚刚入门,行文不准确之处难免,敬请见谅。
本文的后半部分还在整理之中。
1. 什么是机器学习
用最最简单的话来说,机器学习就是不断改进一个模型的过程,使之可以更好的描述一组数据的内在规律。假设,我们拿到若干人的年龄(a1,a2,a3…)和他们的工资(b1,b2,b3…),此时,我们就可以将这些点画在一个二维直角坐标系中,包括(a1,b1),(a2,b2)等等。这些就称为输入或训练数据。
我们可以用数学的最小二乘法拟合一条直线,这样就可以得到最好的可以描述这些数据的规律y=ax+b了。当然,因为我们有很多个点,所以它们可能不在一条直线上,因此任何的直线都不会过它们所有的点,即一定会有误差。
但对于电脑来说,它可以使用一种截然不同的方式来得到y=ax+b中a,b的值。首先,它从一个随便指定的a和b出发(例如a=100,b=1),然后它算出y=100(a1)+1的值和b1的区别,y=100(a2)+1和b2的区别,等等。它发现误差非常大,此时,它就会调整a和b的值(通过某种算法),使得下一次的误差会变小。如果下次的误差反而变得更大了,那就说明,要么是初始值a,b给的不好,要么是y=ax+b可能不是一个好的模型,可能一个二次方程y=a^2+bx+c更好,等等。
经过N轮调整(这称为模型的训练),误差的总和可能已经到了一个稳定的,较小的值。误差小时,a和b的调整相对当然也会较小。此时的a和b就会十分接近我们使用最小二乘法做出来的值,这时,就可以认为模型训练完成了。
当然,这只是机器学习最简单的一个例子,使用的模型也只是线性的直线方程。如果使用更加复杂的模型,机器学习可以做出十分强大的事情。
2. 环境初始化
我使用VS2017创建一个新的控制台应用,然后,使用下面的命令安装TensorflowSharp:
nuget install TensorFlowSharp
TensorflowSharp的源码地址:https://github.com/migueldeicaza/TensorFlowSharp
如果在运行时发现问题“找不到libtensorflow.dll”,则需要访问
下载这个压缩包。然后,在下载的压缩包中的lib中找到tensorflow.dll,将它改名为libtensorflow.dll,并在你的工程中引用它。
这样一来,环境初始化就完成了。
3. TensorflowSharp中的概念
TensorflowSharp / Tensorflow中最重要的几个概念:
图(Graph):它包含了一个计算任务中的所有变量和计算方式。可以将它和C#中的表达式树进行类比。例如,一个1+2可以被看作为两个常量表达式,以一个二元运算表达式连接起来。在Tensorflow的世界中,则可以看成是两个tensor和一个op(operation的缩写,即操作)。简单来说,做一个机器学习的任务就是计算一张图。
在计算图之前,当然要把图建立好。例如,计算(1+2)*3再开根号,是一个包括了3个tensor和3个Op的图。
不过,Tensorflow的图和常规的表达式还有所不同,Tensorflow中的节点变量是可以被递归的更新的。我们所说的“训练”,也就是不停的计算一个图,获得图的计算结果,再根据结果的值调整节点变量的值,然后根据新的变量的值再重新计算图,如此重复,直到结果令人满意(小于某个阈值),或跑到了一个无穷大/小(这说明图的变量初始值设置的有问题),或者结果基本不变了为止。
会话(Session):为了获得图的计算结果,图必须在会话中被启动。图是会话类型的一个成员,会话类型还包括一个runner,负责执行这张图。会话的主要任务是在图运算时分配CPU或GPU。
张量(tensor): Tensorflow中所有的输入输出变量都是张量,而不是基本的int,double这样的类型,即使是一个整数1,也必须被包装成一个0维的,长度为1的张量【1】。一个张量和一个矩阵差不多,可以被看成是一个多维的数组,从最基本的一维到N维都可以。张量拥有阶(rank),形状(shape),和数据类型。其中,形状可以被理解为长度,例如,一个形状为2的张量就是一个长度为2的一维数组。而阶可以被理解为维数。
阶 |
数学实例 |
Python 例子 |
0 |
纯量 (只有大小) |
s = 483 |
1 |
向量(大小和方向) |
v = [1.1, 2.2, 3.3] |
2 |
矩阵(数据表) |
m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] |
3 |
3阶张量 (数据立体) |
t = [[[2], [4], [6]], [[8], [10], [12]], [[14], [16], [18]]] |
Tensorflow中的运算(op)有很多很多种,最简单的当然就是加减乘除,它们的输入和输出都是tensor。
Runner:在建立图之后,必须使用会话中的Runner来运行图,才能得到结果。在运行图时,需要为所有的变量和占位符赋值,否则就会报错。
4. TensorflowSharp中的几类主要变量
Const:常量,这很好理解。它们在定义时就必须被赋值,而且值永远无法被改变。
Placeholder:占位符。这是一个在定义时不需要赋值,但在使用之前必须赋值(feed)的变量,通常用作训练数据。
Variable:变量,它和占位符的不同是它在定义时需要赋值,而且它的数值是可以在图的计算过程中随时改变的。因此,占位符通常用作图的输入(即训练数据),而变量用作图中可以被“训练”或“学习”的那些tensor,例如y=ax+b中的a和b。
5. 基本运算
下面的代码演示了常量的使用:
//基础常量运算,演示了常量的使用 static void BasicOperation() { using (var s = new TFSession()) { var g = s.Graph; //建立两个TFOutput,都是常数 var v1 = g.Const(1.5); var v2 = g.Const(0.5); //建立一个相加的运算 var add = g.Add(v1, v2); //获得runner var runner = s.GetRunner(); //相加 var result = runner.Run(add); //获得result的值2 Console.WriteLine($"相加的结果:{result.GetValue()}"); } }
使用占位符:
//基础占位符运算 static void BasicPlaceholderOperation() { using (var s = new TFSession()) { var g = s.Graph; //占位符 - 一种不需要初始化,在运算时再提供值的对象 //1*2的占位符 var v1 = g.Placeholder(TFDataType.Double, new TFShape(2)); var v2 = g.Placeholder(TFDataType.Double, new TFShape(2)); //建立一个相乘的运算 var add = g.Mul(v1, v2); //获得runner var runner = s.GetRunner(); //相加 //在这里给占位符提供值 var data1 = new double[] { 0.3, 0.5 }; var data2 = new double[] { 0.4, 0.8 }; var result = runner .Fetch(add) .AddInput(v1, new TFTensor(data1)) .AddInput(v2, new TFTensor(data2)) .Run(); var dataResult = (double[])result[0].GetValue(); //获得result的值 Console.WriteLine($"相乘的结果: [{dataResult[0]}, {dataResult[1]}]"); } }
在上面的代码中,我们使用了fetch方法来获得数据。Fetch方法用来帮助取回操作的结果,上面的例子中操作就是add。我们看到,整个图的计算是一个类似管道的流程。在fetch之后,为占位符输入数据,最后进行运算。
使用常量表示矩阵:
//基础矩阵运算 static void BasicMatrixOperation() { using (var s = new TFSession()) { var g = s.Graph; //1x2矩阵 var matrix1 = g.Const(new double[,] { { 1, 2 } }); //2x1矩阵 var matrix2 = g.Const(new double[,] { { 3 }, { 4 } }); var product = g.MatMul(matrix1, matrix2); var result = s.GetRunner().Run(product); Console.WriteLine("矩阵相乘的值:" + ((double[,])result.GetValue())[0, 0]); }; }
6. 求两个点的距离(L1,L2)
求两点距离实际上就是若干操作的结合而已。我们知道,(x1,x2), (y1,y2)的距离为:
Sqrt((x1-x2)^2 + (y1-y2)^2)
因此,我们通过张量的运算,获得
[x1-x2, y1-y2] (通过Sub)
[(x1-x2)^2, (y1-y2)^2] (通过Pow)
然后,把这两个数加起来,这需要ReduceSum运算符。最后开根就可以了。我们把整个运算赋给变量distance,然后fetch distance:
//求两个点的L2距离 static void DistanceL2(TFSession s, TFOutput v1, TFOutput v2) { var graph = s.Graph; //定义求距离的运算 //这里要特别注意,如果第一个系数为double,第二个也需要是double,所以传入2d而不是2 var pow = graph.Pow(graph.Sub(v1, v2), graph.Const(2d)); //ReduceSum运算将输入的一串数字相加并得出一个值(而不是保留输入参数的size) var distance = graph.Sqrt(graph.ReduceSum(pow)); //获得runner var runner = s.GetRunner(); //求距离 //在这里给占位符提供值 var data1 = new double[] { 6, 4 }; var data2 = new double[] { 9, 8 }; var result = runner .Fetch(distance) .AddInput(v1, new TFTensor(data1)) .AddInput(v2, new TFTensor(data2)) .Run(); Console.WriteLine($"点v1和v2的距离为{result[0].GetValue()}"); }
最后,我们根据目前所学,实现KNN识别MNIST。
7. 实现KNN识别MNIST(1)
什么是KNN
K最近邻(k-Nearest Neighbor,KNN)分类算法,是一个理论上比较成熟的方法,也是最简单的机器学习算法之一。该方法的思路是:如果一个样本在特征空间中的k个最相似(即特征空间中最邻近)的样本中的大多数属于某一个类别,则认为该样本也属于这个类别。
图中,绿色圆要被决定赋予哪个类,是红色三角形还是蓝色四方形?如果K=3,由于红色三角形所占比例为2/3,绿色圆将被赋予红色三角形那个类,如果K=5,由于蓝色四方形比例为3/5,因此绿色圆被赋予蓝色四方形类。
在进行计算时,KNN就表现为:
- 首先获得所有的数据
- 然后对一个输入的点,找到离它最近的K个点(通过L1或L2距离)
- 然后,对这K个点所代表的值,找出最多的那个类,那么,这个输入的数据就被认为属于那个类
对MNIST数据的KNN识别,在读入若干个输入数据(和代表的数字)之后,逐个读入测试数据。对每个测试数据,找到离他最近的K个输入数据(和代表的数字),找出最多的代表数字A。此时,测试数据就被认为代表数字A。因此,使用KNN识别MNIST数据就可以化为求两个点(群)的距离的问题。
MNIST数据集
MNIST是一个非常有名的手写数字识别的数据集。它包含了6万张手写数字图片,例如:
当然,对于我们人类而言,识别上面四幅图是什么数字是十分容易的,理由很简单,就是“看着像”。比如,第一张图看着就像5。但如果是让计算机来识别,它可无法理解什么叫看着像,就显得非常困难。实际上,解决这个问题有很多种方法,KNN是其中最简单的一种。除了KNN之外,还可以使用各种类型的神经网络。
我们可以将每个图片看成一个点的集合。实际上,在MNIST输入中,图片被表示为28乘28的一个矩阵。例如,当我们成功读取了一张图之后,将它打印出来会发现结果是这样的(做了一些处理):
其中,数字均为byte类型(0-255),数字越大,代表灰度越深。当然,0就代表白色了。因此,你可以想象上面的那张图就是一个手写的2。如果把上图的000换成3个空格可以看的更清楚:
对于每张这样的图,MNIST提供了它的正确答案(即它应该是代表哪个数字),被称为label。上图的label显然就是2了。因此,每张输入的小图片都是一个28乘28的矩阵(含有784个数字),那么,我们当然也可以计算任意两个小图片的距离,它就是784个点和另外784个点的距离之和。因此,如果两张图的距离很小,那么它们就“看着像”。在这里,我们可以有很多定义距离的方式,简单起见,我们就将两点的距离定义为L1距离,即直接相减之后取绝对值。例如,如果两个图片完全相同(784个数字位置和值都一样),那么它们的距离为0。如果它们仅有一个数字不同,一个是6,一个是8,那么它们的距离就是2。
那么,在简单了解了什么是KNN之后,我们的任务就很清楚了:
- 获得数据
- 把数据处理为一种标准形式
- 拿出数据中的一部分(例如,5000张图片)作为KNN的训练数据,然后,再从数据中的另一部分拿一张图片A
- 对这张图片A,求它和5000张训练图片的距离,并找出一张训练图片B,它是所有训练图片中,和A距离最小的那张(这意味着K=1)
- 此时,就认为A所代表的数字等同于B所代表的数字b
- 如果A的label真的是b,那么就增加一次获胜次数
通过多次拿图片,我们就可以获得一个准确率(获胜的次数/拿图片的总次数)。最后程序的输出如下:
在下一篇文章中会详细分析如何实现整个流程。