摘要
第3章张量与计算图第2章我们尝试了深度学习的乐趣。“万丈高楼平地起”,我们看到了目标之后,并不等于有捷径达到目标,还是需要踏踏实实地从基本的元素开始学习。Google的深度学习框架名称为TensorFlow,可见Tensor张量对于这个框架的重要性。另外,张量虽然很重要,但是如何将张量组合起来也同样重要。目前主流的框架都是通过计算图的方式来将张量组织起来。我们通过静态计算图的典型案例TensorFlow和动态计算图的典型PyTorch,可以深刻地理解计算图的本质。本章将介绍以下内容0维张量:标量计算图与流程控制变量3.1 0维张量:标量所谓张量,从程序员的角度理解,就是一个多维的数组。根据维数不同,不同的张量有不同的别名。0维的张量,称为标量。标量虽然最简单,但是也涉及数据类型、算术运算、逻辑运算、饱和运算等基础操作。1维的张量,称为向量。从向量开始,我们就不得不引入Python 生态的重要工具库——NumPy。作为静态计算图代表的TensorFlow,几乎是接近实现了一套NumPy 的功能,所以它的跨语言能力很强。而PyTorch对于NumPy能支持的功能全部复用,只对NumPy缺少的功能进行补充。例如,NumPy计算无法使用GPU进行加速,而PyTorch就实现了NumPy功能的GPU加速。可以将PyTorch理解为可借用GPU进行加速的NumPy库。2维的张量,称为矩阵。在机器学习中对于矩阵有非常多的应用,这里涉及不少数学知识。本章我们只讲基本概念和编程,第4章会专门讲矩阵。3维以上的张量是我们一般意义上所认为的真正的张量。在深度学习所用的数据中,我们基本上都用高维张量来处理,并根据需要不停地将其变换成各种形状的张量。标量可以理解为一个数。虽然一个数从某方向来讲已是最简单的结构,但是对于机器学习来说,也涉及不少问题,如数据类型、计算精度。很多的优化也跟数据类型相关。3.1.1 TensorFlow的数据类型在学习数据类型之前,我们先看一下TensorFlow和PyTorch风格的数据类型对比图。如图3.1所示,TensorFlow比PyTorch多了复数类型。其实总体来说TensorFlow的类型丰富程度远胜于PyTorch。TensorFlow的数据类型分为整型、浮点型和复数型3种,基本上对应NumPy的整型、浮点型和复数型,只不过每种类型的子类型比NumPy更少一些。而PyTorch比TensorFlow更加精简。原因是TensorFlow是静态计算图语言,相对要比较完备。而PyTorch是动态计算图语言,可以借助NumPy的功能。图3.1 TensorFlow和PyTorch的数据类型对比具体类型如图3.2所示。图3.2 TensorFlow数据类型其实,除了上面的基本类型以外,TensorFlow还支持量子化的整型,分别是8位的无符号quint8,8位有符号qint8,16位的无符号quint16,16位有符号qint16,还有32位有符号的qint32。在TensorFlow中,基本上所有涉及数字类型的函数都可以指定类型。最为常用的就是常量。TensorFlow 是一门静态计算图语言,与普通计算机高级语言一样,定义了常量、变量等常用编程结构。数字常数被TensorFlow使用之前,首先要赋值一个TensorFlow常量。例如:>>> import tensorflow as tf>>> a = tf.constant(1, dtype=tf.float64)这样,a就是一个float64类型值为1的常量。常量也是张量的一种。>>> a这个常量是一个静态计算图,如果需要获取计算结果,需要建立一个会话来运行:>>> sess = tf.Session()>>> b = sess.run(a)>>> print(b)1.0我们再来看复数的例子:>>> c = 10 + 5.2j>>> b = tf.constant(c)将复数10+5.2j赋给c,再通过c创建一个常量。这个常量就是一个tf.complex128类型的常量。>>> b3.1.2 PyTorch的数据类型PyTorch没有对应NumPy的复数类型,只支持整型和浮点型两种。种类也比TensorFlow要少一些,更加精练。PyTorch相当于带有GPU支持的NumPy,缺什么直接用NumPy即可,如图3.3所示。PyTorch是动态计算图语言,不需要tf.constant之类的常量,数据计算之前赋给一个张量即可。建立Tensor是可以指定类型的。例如:>>> import torch as t>>> a1 = t.tensor(1, dtype=t.float32)>>> a1tensor(1.)图3.3 PyTorch数据类型除了通过指定dtype的方式,我们还可以通过直接创建相应的Tensor子类型的方式来创建Tensor。例如:>>> a2 = t.FloatTensor(1)>>> a2tensor([ 0.])具体类型对应关系如表3.1所示。表3.1 PyTorch数据类型与张量子类型对应关系数据类型类型名称类型别名张量子类型16位浮点数halffloat16HalfTensor32位浮点数floatfloat32FloatTensor64位浮点数doublefloat64DoubleTensor8位无符号整数uint8无ByteTensor8位带符号整数int8无CharTensor16位带符号整数int16shortShortTensor32位带符号整数int32intIntTensor64位带符号整数int64longLongTensor3.1.3 标量算术运算标量虽然没有矩阵运算那么复杂,但它是承载算术和逻辑运算的载体。对于TensorFlow,标量算术运算还是需要先生成静态计算图,然后通过会话来运行。例如:>>> a1 = tf.constant(1, dtype=tf.float64)>>> a2 = tf.constant(2, dtype=tf.float64)>>> a3 = a1 + a2>>> print(a3)Tensor("add:0", shape=(), dtype=float64)>>> sess.run(a3)3.0对于PyTorch来说,就是两个张量相加,结果还是一个张量,比TensorFlow要简单一些,也不需要会话。例如:>>> b1 = t.tensor(3, dtype=t.float32)>>> b2 = t.tensor(4, dtype=t.float32)>>> b3 = b1 + b2>>> b3tensor(7.)除了加减乘除之外,不管是TensorFlow还是PyTorch,都提供了足以满足需求的数学函数。例如三角函数,TensorFlow版计算余弦值:>>> a20 = tf.constant(0.5)>>> a21 = tf.cos(a20)>>> sess.run(a21)0.87758255PyTorch版计算余弦值:>>> b20 = t.tensor(0.5)>>> b21 = t.cos(b20)>>> b21tensor(0.8776)在NumPy里,对于数据类型相对是比较宽容的,如sqrt、NumPy既支持整数,也支持浮点数:>>> np.sqrt(20)4.47213595499958>>> np.sqrt(20.0)4.47213595499958而对于TensorFlow和PyTorch来说,sqrt只支持浮点数,用整数则会报错。TensorFlow版的报错信息,根本不用在会话中执行,创建计算图时就报错:>>> a22 = tf.constant(20, dtype=tf.int32)>>> a23 = tf.sqrt(a22)Traceback (most recent call last):File "", line 1, in …TypeError: Value passed to parameter 'x' has DataType int32 not in list of allowed values: bfloat16, float16, float32, float64, complex64, complex128PyTorch的报错相对简洁,直接声明,没有实现对于IntTensor类型的torch.sqrt函数:>> b22 = t.tensor(10,dtype=t.int32)>>> b23 = t.sqrt(b22)Traceback (most recent call last):File "", line 1, in RuntimeError: sqrt not implemented for 'torch.IntTensor'当然,不是每个函数都要求这么严格,例如,abs求绝对值函数,整数和浮点数都支持:>>> a10 = tf.constant(10, dtype=tf.float32)>>> a11 = tf.abs(a10)>>> sess.run(a11)10.0>>> a12 = tf.constant(20, dtype=tf.int32)>>> a13 = tf.abs(a12)>>> sess.run(a13)203.1.4 Tensor与NumPy类型的转换不管TensorFlow还是PyTorch,都跟NumPy有很深的渊源,它们之间的转换也非常重要。1.PyTorch与NumPy之间交换数据PyTorch有统一的接口用于与NumPy交换数据。PyTorch的张量(Tensor)可以通过NumPy 函数来转换成NumPy的数组(ndarray)。例如:>>> b10 = t.tensor(0.2, dtype=t.float64)>>> c10 = b10.NumPy()>>> c10array(0.2)>>> b10tensor(0.2000, dtype=torch.float64)作为逆运算,对于一个NumPy 的数组对象,可以通过torch.from_numpy 函数转换成PyTorch的张量。例如:>>> c11 = np.array([[1,0],[0,1]])>>> c11array([[1, 0],[0, 1]])>>> b11 = t.from_NumPy(c11)>>> b11tensor([[ 1, 0],[ 0, 1]])2.TensorFlow与NumPy之间的数据交换TensorFlow与NumPy就是构造计算图与执行计算图的过程。将NumPy的数组转成TensorFlow 的Tensor,可以通过tf.constant 来构造一个计算图。而作为逆运算的从TensorFlow的张量转换成NumPy的数组,可以通过创建一个新会话运行计算图。示例1,从ndarray到Tensor:>>> a11array([[1, 0],[0, 1]])>>> c11 = tf.constant(a11)>>> c11示例2,从Tensor到ndarray:>>> a11 = sess.run(c11)>>> a11array([[1, 0],[0, 1]])另外,TensorFlow还提供了一系列的转换函数。例如,to_int32函数可以将一个Tensor的值转换为32位整数:>>> a01 = tf.constant(0, tf.int32)>>> a02 = tf.to_int32(a01)>>> sess.run(a02)0类似的函数还有tf.to_int64、tf.to_float、tf.to_double等,基本上每个主要类型都有一个。定义这么多函数太麻烦,但还是有通用的转换函数tf.cast。格式为:tf.cast(Tensor, 类型名)。例如:>>> b05 = tf.cast(b02, tf.complex128)>>> sess.run(b05)(1+0j)3.TensorFlow的饱和数据转换TensorFlow定义了这么多转换函数,有什么好处?答案是功能多。以TensorFlow还支持饱和转换为例,我们将大类型如int64转换成小类型int16,tf.cast转换过程中可能产生溢出,这在机器学习的计算中是件可怕的事情,而使用饱和转换,最多是变成小类型的优选值,而不会变成负值。例如,把65536转换成tf.int8类型。我们知道,int8只能表示-128到127之间的数。使用饱和转换tf.saturate_cast,只要是大于127的数值,转换出来就是127,不会更大。例如:>>> b06 = tf.constant(65536,dtype=tf.int64)>>> sess.run(b06)65536>>> b07 = tf.saturate_cast(b06,tf.int8)>>> sess.run(b07)1273.2 计算图与流程控制计算图是一种特殊的有向无环图DAG,用来表示变量和操作之间的关系。它有点类似于流程图,但是比流程图多了对变量的描述。既然与流程图类似,那么计算图其实相当于是一种计算机语言,需要有完备的计算机语言的功能。我们知道,一种结构化的计算机语言,除了顺序结构之外,还需要有分支结构和循环结构。下面,我们分别看下静态计算图及其代表TensorFlow与动态计算图及PyTorch如何实现计算图。3.2.1 静态计算图与TensorFlow静态计算图有点像编译型的语言,首先要写好完整的代码,然后再编译成机器指令并执行。会话中运行,相当于在TensorFlow机器上运行。所以TensorFlow的静态计算图语言中,需要包括完整的指令集。因为不能借助宿主机,需要有条件分支指令、循环指令,甚至为了辅助开发,还需要一些调试指令。具体如图3.4所示。图3.4 TensorFlow主要流程控制功能1.比较运算分支的第一步是要有比较指令。最简单的指令当然是判断是否相等,TensorFlow提供了equal函数来实现此功能,例如:>>> c1 = tf.constant(1)>>> c2 = tf.constant(2)>>> c3 = tf.equal(c1,c2)>>> sess.run(c3)False结果返回False,说明c1和c2这两个张量不相等。我们用判断不相等的not_equal函数来比较,结果就会为True,如下例:>>> c4 = tf.not_equal(c1,c2)>>> sess.run(c4)True如果比较大小,可以用小于less和大于greater两个函数,如下例:>>> c5 = tf.less(c1,c2)>>> sess.run(c5)True>>> c6 = tf.greater(c1,c2)>>> sess.run(c6)False除了相等、不等、大于、小于之外,还有大于或等于操作greater_equal和小于或等于操作less_equal,我们看两个例子:>>> c7 = tf.less_equal(c1,c2)>>> sess.run(c7)True>>> c8 = tf.greater_equal(c1,c2)>>> sess.run(c8)False另外,比较大小还可以批量进行,判断条件是一个布尔型的数组,后面是根据不同情况赋的值,这是where操作的功能。我们在后面学习向量之时再详细介绍。2.逻辑运算通过上面的6种比较运算,我们可以在TensorFlow的计算流程图中对比较运算的结果进行逻辑运算。逻辑运算一共有以下4种:? 与:logical_and;? 或:logical_or;? 非:logical_not;? 异或:logical_xor。我们举几个例子来介绍,首先是取非的例子:>>> c9 = tf.logical_not(tf.greater_equal(c1,c2))>>> sess.run(c9)True再用greater、equal和logical_or来实现greater_equal功能,代码如下:>>> c11 = tf.constant(1)>>> c12 = tf.constant(2)>>> c13 = tf.logical_or(tf.greater(c11,c12), tf.equal(c11,c12))>>> sess.run(c13)False例如,我们用数字来调用logical_xor,代码如下:c10 = tf.logical_xor(1,2)将报错如下:TypeError: Expected bool, got 1 of type 'int' instead.如果用两个整数类型的张量去进行逻辑运算,将报错如下:ValueError: Tensor conversion requested dtype bool for Tensor with dtype int32: 'Tensor("Const:0", shape=(), dtype=int32)'习惯了C语言的弱类型的读者请特别注意以上情况。3.流程控制——分支结构有很多人在学到TensorFlow中还有流程控制操作时觉得很奇怪,这是对于静态计算图的理解还不够清楚的体现。再次强调一下,静态计算图就是一门程序设计语言,所以流程控制是非常重要的基本功能。流程控制的基础是分支功能,就像Python中的if判断一样。在TensorFlow中,可以用case操作来实现。我们一步步来展示case的功能。最简单的case语句只有一个判断条件和一个对应和函数。我们来看例子:>>> d1 = tf.constant(1)>>> d2 = tf.case([(tf.greater(d1,0), lambda : tf.constant(0))])>>> sess.run(d2)0解释一下上述语句,如果d1大于0,执行后面的lambda函数,返回一个值为0的常量。下面可以给case语句增加一个default分支:>>> d3 = tf.case([(tf.greater(d1,0), lambda : tf.constant(0))], default=lambda : tf.constant(-1))>>> sess.run(d3)0然后尝试加多个分支:>>> d4 = tf.case([(tf.greater(d1,1), lambda : tf.constant(2)),(tf.equal(d1,1),lambda : tf.constant(1))], default=lambda : tf.constant(-1))>>> sess.run(d4)1如果d1大于1,返回值为2的常量。如果d1等于1,则返回值为1的常量。4.流程控制——循环结构我们来看看循环结构,最常用的循环是for循环,对应TensorFlow的操作是while_loop操作。我们看个例子:>>> e0 = tf.constant(1)>>> e1 = tf.while_loop(lambda i : tf.less(i,10), lambda j : tf.add(j,1), [e0])>>> sess.run(e1)我们定义e0用作循环控制变量,while_loop操作最少需要3个参数,第一个参数是结束循环的判断条件,第二个参数是循环体,第三个参数是循环控制变量。所谓循环体,是需要循环多次操作的具体功能,在我们这个例子中,是一个给循环控制变量加1 的操作。循环控制变量可以设计得很复杂,它会作为参数传给前面的第一个和第二个callable参数。例如,第一个参数,我们给的是一个lambda表达式,并没有指定要传的参数,形参的名称与实际传入的无关。循环控制变量参数获取之后,才会把这个参数传给lambda表达式去执行。5.程序调试当我们写了比较复杂的流程控制到静态计算图中,需要一些调试手段来测试逻辑是否正确。最基础的功能是可以打印输出debug信息,这可以通过Print函数实现。例如:>>> e2 = tf.Print(tf.constant(0),['Debug info'])