- 深度学习程序设计实战
- 方林 陈海波编著
- 6446字
- 2025-02-25 14:02:53
第3章
Chapter Three
神经元网络初步
3.1 Tensorflow基本概念
3.1.1 计算图、张量、常数和变量
前文已经说过,TF的张量等价于表达式Exp,计算图等价于依赖关系图,所以创建张量的过程等价于创建一个表达式。表达式Const(3)+Variable(′x′)与3+x的区别是前者创建了一个新的表达式对象,并建立了一个如图3-1所示的依赖关系图;而后者则直接从内存中取得x的值,然后与3相加,不会建立什么依赖关系图。

图3-1 依赖关系图示例
换句话说,在表达式之间执行加法运算时,并不进行数值计算,而是创建一个加法表达式;而在常数和变量之间执行加法运算则会导致计算的发生。前者等价于创建一个Exp类型的对象,后者等价于对Exp对象执行eval()操作。这两者当然是不一样的。
在TF中也有常数和变量,就像Exp有子类Const和Variable一样。有时,为了避免与Python(或者任何一个高级程序设计语言)中的常数、变量混淆,我们又分别称它们为常数张量和变量张量。下文中,如果不特别说明,常数、变量、各种运算等指的都是张量。
例如引入TF(即import tensorflow as tf)之后,tf.constant(123)和tf.constant([45,67,89])表示TF中的常数。它们的值分别是标量123和向量[45,67,89]。这里,常数的“值”这个概念等价于Exp的子类Const的value属性(即成员变量,以下同)。
Const的value属性是有类型的,例如int、float、bool、str等。与之对应,TF中的常数的值也有类型,分别是整型、浮点型、tf.bool和tf.string等。与Python的基本数据类型int不同,TF的整型又分为tf.int8、tf.int16、tf.int32、tf.int64四种,分别占1、2、4、8个字节。TF的不同整数类型是不通用的,要想对两个不同长度的整型进行运算,就要用tf.cast()函数改变其中一个张量的长度。例如,tf.cast(x,tf.int16)就能把张量x转成2字节整型。
在高级语言中,例如Python、Java、C和C++等,不同长度的整型数据是通用的。这是因为编译器或者解释器能够自动地帮我们把长度短的整型数据加长。例如在2字节整型的左边(高位)再增加2个字节的0,就可以把一个非负整数转为4字节整数,还能保持值不变。那为什么TF不这样做呢?那是因为TF主要针对的是高维矩阵数据,如果允许整型相互之间直接通用,在一个矩阵中就势必要允许存在长度不同的整数。运算前要想对齐这些整数会很麻烦,很耽误时间。换句话说,高级语言编译器或者解释器主要面对的是一个标量整数,它的类型和转换方法是确定的;而TF面对的是一堆整数,各种长度都有可能。为了避免在这一不太重要的地方浪费运算时间,TF就不允许不同长度的整数聚集在同一个矩阵中,从而也就不允许直接通用不同长度的整数及其矩阵。
读者可以试着执行下面的代码,会发现程序报错。

TF中的浮点类型也分为tf.float16、tf.float32和tf.float64三种类型,长度分别是2、4和8个字节,相互之间也不通用。
TF中的另一个基本概念是变量(等价于Exp中的Variable)。与高级语言中的变量一样,TF中的变量也可以拥有值(标量、向量或者高维矩阵),或者被赋予新的值。创建一个变量最简单的办法是调用tf.get_variable(name,shape,dtype)函数。例如,tf.get_variable(′xyz′,[3,2],tf.float32)表示创建一个名为xyz的变量,值是3 ×2的矩阵,矩阵中的元素是32位浮点数。这里,[3,2]表示矩阵的形状(Shape),即矩阵有几个维度(Dimension),每个维度的大小是多少[1]。TF用Python的列表(list)表示张量的形状,该列表的长度被称为阶(Rank),又称为维度数。例如形状[3,8,5]的阶就是3。标量的形状是空列表[],阶为0。
TF的变量与Python变量的区别是:
1)TF的变量是个张量,即计算图中的一个结点。它的性质与其他结点(例如加法或者relu函数)相同。而Python变量是内存中的一块区域,其中可能存放着一个基本类型值(例如整数、浮点数或者一个布尔值),也可能存放着一个对象的地址。
2)TF的变量是不可以直接赋值的,就像不会给一个表达式加法或者log函数赋值一样。要想给一个TF的变量a赋值,请首先执行tf.assign(a,x),然后执行一个会话(Session,后面介绍)对象的run()方法,才能完成对a的赋值。而Python变量是可以直接赋值的,例如a=3就是把3赋给了a这个Python变量。甚至可以把一个张量赋值给一个Python变量,例如a=tf.constant(12345)或者a=tf.get_variable(′xyz′,[5,8,2],tf.float32)[2]。在Python看来,张量不过是一个对象而已,当然可以赋值给一个Python变量,以便在随后的代码中可以通过这个Python变量来引用该张量。
3)定义一个TF变量至少要指明其名字、形状和值类型;而定义一个Python变量时不需要指明任何属性,连名字都不用。Python变量的实质是对对象或者Python基本类型值的引用;而TF变量是一个tf.Variable类型的对象,有自己的属性和方法,能够参与构建计算图(就像Exp的子类Variable可以参与构建复合函数一样)。它就是它自己,不是对其他任何对象或者别的什么东西的引用。
4)TF变量的值随会话的不同而不同。事实上,脱离会话也就无所谓TF变量的值是什么。就像Exp的子类Variable的内部成员函数eval(∗∗env)可以计算该变量的值,前提是必须提供env参数作为环境。离开环境也就无所谓变量的值是什么。
5)当把一个含有10000个元素的列表赋值给一个Python变量a时,这意味着内存中存在着一个占据10000个单位内存的大型对象;而创建一个含有10000个元素的TF变量时,例如tf.get_ variable(′abc′,[10000],tf.int32),内存里除了多出一个名为abc的张量外(这个张量占据的内存很少),不会存在一个占据大量内存的大型对象。
下面是一段TF的会话与常数、变量之间关系和互动的代码示例:

3.1.2 会话、运行
我们知道,在Exp及其子类对象中有一个eval(∗∗env)方法。参数字典env给出了一个对变量进行求值的环境。TF中的会话(Session)则起到了对张量进行求值的环境作用。Session的run(tensors,feed_dict=None)方法就相当于Exp的eval()方法。其中第一个参数tensors是要求解的张量或者张量列表,第二个参数feed_dict是一个字典(dict)类型对象,它的键必须是张量,值是要给这个张量赋予的初值(见3.1.3节)。
如代码3-2所示,应该在with语句之下执行run()函数,这是因为我们要保证会话(或者说环境)正常地打开或者关闭。当然,也可以直接执行Session._enter_()、Session._exit_()或Session.close()方法手工打开或者关闭会话,但不建议这样做。因为with语句能保证即使其下方的语句出现异常,Session._exit_()也能被调用,从而保证会话能被正常地关闭[3]。
TF的会话还具有保存和恢复模型、设置GPU运行参数、为占位符张量提供数据的作用。我们会在后面的章节中逐步学习。
3.1.3 占位符
除了常数和变量之外,TF还有一种奇特的张量:占位符。调用tf.placeholder()可以创建一个占位符。构建一个占位符同样需要提供值类型、形状和名字,就像定义一个常数一样(参数的次序不一样)。名字虽然不是必需的,但是本书建议提供一个有意义的名字,这样在出错的时候可以帮助定位代码。常数能参与的运算,例如算术运算、关系运算、逻辑运算等,占位符一样能够参与。两者的区别是常数的值永远不会变,而占位符的值可以在会话运行时(Session.run())提供。示例代码如下:

打印结果:[101 202 303]。由于占位符的性质是初值可以改变的常数,所以占位符常被用来接受用户在运行一个张量时提供的输入数据。但是,这并不是用户输入数据的唯一方法。
假设a=tf.constant([1,2,3],tf.int8),b=tf.constant([4,5,6],tf.int8),c=a+b。运行时,c的值当然变成了[5,7,9]。但是,我们也可以在run时直接设定a或者b张量的值。下面是示例:


打印结果是:[50,70,90]。所以,只要是张量,我们都可以在调用Session.run()函数时,通过feed_dict参数设定它的值,而不是仅仅只有placeholder张量才可以被设定值。例如图3-1中的加法张量依赖于x和3。一般情况下,如果要计算加法的值就必须先求得x和3的值。可是,如果在Session.run()的feed_dict中设置这个加法张量的值,那x和3的值就不会被求解。这是因为张量的实质是计算图(或者说依赖关系图)中的结点。任意结点的值既可以通过所依赖的结点计算,也可以直接人为设定。这就为计算图的使用提供了多种可能,为使用者带来了很多便利。
3.1.4 矩阵算术运算
TF中矩阵的算术运算是指有运算符的数学运算,如加法(+)、减法(-)、乘法(∗)、除法(/)、求反(-)、乘方(∗∗)等。
形状相同的两个矩阵进行算术运算(以及对一个矩阵求反)的结果可以得到同样形状的一个矩阵,其中每一个元素就是对这两个矩阵(或求反的那个矩阵)中相应位置处元素进行该算术运算的结果。下面是算术运算示例:

运行结果:

3.1.5 矩阵运算的广播
我们知道,在数学上,标量3与矩阵A相乘的结果就是3与A的每一个元素相乘构成的矩阵。这在TF中也成立,且对几乎所有两元矩阵运算(包括算术运算以及后面提到的关系运算、函数等)都成立。TF要求两个参与运算的矩阵(含标量、向量,以下同)的形状a和b必须是相容的。相容性的递归定义如下:
1)如果a= =b,即两个矩阵的形状完全相同,则a、b是相容的,且运算结果的形状等于a。例如形状都是[3,5,2]的两个矩阵可以进行任意二元算术运算,且结果的形状也是[3,5,2]。
2)如果a==[]或者b==[],即两个矩阵中有一个是标量,则a、b是相容的,且结果矩阵的形状等于a、b中不为空的那个列表。如果a、b都是空列表,则结果也是空列表。例如形状为[3,5,2]的矩阵可以和任何标量(标量的形状为[])进行任意二元算术运算,结果的形状仍是[3,5,2]。
3)如果a与b相容,则b与a也相容,且结果的形状等于a与b进行二元算术运算的结果形状,即相容性是对称的。
4)如果a、b相容,c是任意一个正整数列表,则a+c与b+c也相容,且结果形状等于a、b的结果形状+c。例如形状[3,5]与形状[5]相容,结果形状是[3,5]。
5)如果a、b相容,则a+[n]与b+[1]也相容,且结果形状等于a、b的结果形状+[n]。例如形状[3,5,2]与形状[3,5,1]相容,结果形状是[3,5,2]。
规则1)、2)和3)比较好理解,规则4)和5)的含义是指可以在相容的两个形状的尾部添加数量相同的维度,只要它们一一对应相等或者对应的两个新增维度中有一个是1即可,示例见表3-1。
表3-1 相容和不相容形状示例

假设矩阵A、B的形状分别是[3,5]和[5],则A+B的结果的形状是[3,5]。TF的做法是把B重复3遍,然后分别与A的每一行相加,从而得到形状为[3,5]的结果。如果A、B的形状分别是[3,5,2]和[3,1,2],则A+B相当于3个[5,2]矩阵和3个[1,2]矩阵分别相加。其中的[1,2]矩阵只有1行2列,这一行数据被重复了5遍,然后分别与对应[5,2]矩阵的每一行相加即得到结果。
这个现象称为矩阵运算的广播(Broadcasting)。在Python的常用包numpy中,矩阵运算也是可以广播的。事实上,TF的矩阵运算不但类似于numpy,而且Session.run()的结果就是numpy.ndarray数据。不同的是,TF的运算元是张量,numpy的运算元是真实存在的矩阵数据。
3.1.6 TF矩阵运算
TF中可用于矩阵运算的基本函数主要有:
1)矩阵乘法tf.matmul()。
2)对数tf.log()。
3)三角函数,如tf.sin()、tf.cos()、tf.tan()等。
4)反三角函数,如tf.asin()、tf.acos()、tf.atan()等。
还有一些与神经元网络搭建有关的数学函数,例如激活函数、全连接操作、卷积操作等。后面会有章节专门讲述,这里没有列出。
其他函数比较容易理解,且在神经元网络中使用较少,我们这里只讲矩阵乘法tf.matmul(a,b)。根据线性代数中对矩阵乘法的要求,如果a、b都是二维矩阵张量,且形状分别是[m,n]和[n,p],则tf.matmul(a,b)的结果就是一个形状为[m,p]的张量。代码示例如下:

运行结果:
[[82 88]
[199 214]]
矩阵乘法不是可广播的。参与矩阵乘法的两个矩阵的阶必须相等,且大于等于2。如果大于2,则除了最右边两个维度之外,其他维度必须一一对应相等。例如,形状分别是[3,2,9]和[3,9,5]的两个矩阵可以matmul,结果形状为[3,2,5]。TF把前者和后者分别看成是3个[2,9]和3个[9,5]形状的矩阵,让它们分别对应相乘,最后再把结果拼接在一起即可。
形状分别是[3,2,9]和[1,9,5]的两个矩阵就不能进行矩阵相乘。
3.1.7 形状和操作
矩阵的形状是一个列表,例如[2,3,4]代表的是一个2×3×4的矩阵,阶为3,共有24个元素。一个标量的形状是[],即空列表;一个向量的形状形如[n],其中n是这个向量的长度。注意,[2,3,4]作为形状,它的阶是3;作为一维矩阵,它的形状是[3],阶是1。
针对形状的操作主要有以下几个:
第一,整形操作,即tf.reshape(tensor,new_shape)。tensor是一个张量,这个函数会把它转成new_shape形状,然后作为结果返回。整形操作能够执行的前提是,new_shape所蕴含的元素的个数必须与矩阵当前的元素总数相同。
假设tensor的形状为[2,3,4],则它可以被整形为以下形状中的任何一种:[24]、[1,24]、[2,12]、[12,2]、[6,4]、[4,6]、[1,8,3]、[3,1,8]、[1,2,2,2,3]、[2,1,3,1,4]……
整形操作常常被用来改变一个矩阵的形状,从而使它可以和另一个形状不同且不满足广播条件的矩阵进行运算。例如,形状[2,3,4]整形为[3,8]之后就可以和形状[2,1,8]进行可广播的运算(如加、减、乘、除等)。而本来,它们是不可以这样运算的。
第二,转置操作,即tf.transpose(tensor,perm)。把张量tensor的各个维度重新排列,perm是维度的新排列次序。假设tensor的阶等于n,则perm必须是0,1,2,…,n-1这n个数的一个排列,代表各维度的新次序。例如,假设perm=[1,0],则形状为[3,9]的矩阵就会被转置为[9,3]。
转置操作的意义在于不仅改变了一个矩阵的形状,还改变了元素参与运算的次序。假设一个矩阵A的形状是[4,8,2],则从左到右,维度的优先级依次升高。运算时,A[0,0,0]总是第一个被提取,接着按照优先级改变下标提取下一个数,即A[0,0,1],接着是A[0,1,0],A[0,1,1],A[0,2,0],…,A[0,7,1],A[1,0,0],…,A[3,7,1]。两个可运算的矩阵就是这样各自提取数据,然后再一对对地进行运算的,而tf.transpose()会改变这种次序。代码示例如下:

第三,升维操作,即让矩阵的阶变大。最简单的办法是把若干形状相同的矩阵并列在一个列表里。例如假设A的形状是[3,4],则[A,A]的形状就是[2,3,4]。这样增加的维度在最左边。如果想在任意一个位置增加维度,请调用tf.expand_dims(A,1),得到形状[3,1,4][4]。其中第二个参数axis表示新维度所在位置(从0开始数)。新维度的长度总是1,这样可以保证矩阵中元素的数量一致。
第四,降维操作,即让矩阵的阶变小。这主要是指reduce打头的一系列函数,如tf.reduce_sum()、tf.reduce_prod()、tf.reduce_mean()、tf.reduce_max()、tf.reduce_min()、tf.reduce_all()、tf.reduce_any()。下面举例说明:
假设矩阵A的形状是[2,3,4],则tf.reduce_sum(A,1)的含义是消除维度1(从0开始数),这样最后得到的形状是[2,4]。tf.reduce_sum(A,[0,2])是消除维度0和2,得到形状[3]。第二个参数axis可以省略,此时表示消除所有维度。所以tf.reduce_sum(A)的结果形状是[],即结果是标量。当然也可以设置第三个参数keepdims=True,表示保留axis指定的维度,仅仅把它的长度变为1。例如tf.reduce_sum(A,[0,2],True)的结果形状是[1,3,1]。
reduce_sum具体是怎样进行降维操作的呢?例如B=tf.reduce_sum(A,1),把形状从[2,3,4]变为[2,4],元素总数从24个减为8个,这意味着每三个数变成一个数。对于B[i,j]来说,它的值来源于A[i,0,j]、A[i,1,j]和A[i,2,j]三个数的求和。代码示例如下:


其他reduce函数与reduce_sum()类似。不同之处仅仅在于对数据集合做什么操作:
1)tf.reduce_sum(),对数据集合求和。
2)tf.reduce_prod(),对数据集合求乘积。
3)tf.reduce_mean(),对数据集合求平均数。
4)tf.reduce_max(),对数据集合求最大值。
5)tf.reduce_min(),对数据集合求最小值。
6)tf.reduce_all(),对布尔值数据集合求逻辑与。
7)tf.reduce_ any(),对布尔值数据集合求逻辑或。
后面章节中我们会用更多的例子说明reduce系列函数的用法。
3.1.8 关系运算和逻辑运算
关系运算符有6个:>、> =、= =、!=、<、< =,对应的函数分别是tf.greater()、tf.greater_equal()、tf.equal()、tf.not_equal()、tf.less()、tf.less_equal(),都带有两个参数。例如矩阵A>B的结果是一个布尔值矩阵,其中的每个元素是A、B中对应元素进行大于比较运算后的结果。其他关系运算符类似。
逻辑运算有4个:tf.logical_not()、tf.logical_and()、tf.logical_or()、tf.logical_xor()。其含义这里不再赘述。要注意的是,逻辑运算是没有运算符的。
关系运算和逻辑运算都是可广播的。