第 3 章 TensorFlow基础概念

第 3 章 TensorFlow基础概念

作为深度学习库中的后起之秀,TensorFlow与Theano、Caffe等前辈软件一样使用基于声明式编程的数据流图作为编程范式。与更为程序员所熟知的结构化编程、面向对象编程相比,这种编程范式具有些许不同的风格。无论初入算法模型开发领域的新人,还是具有其他平台使用经验的老手,都有必要理解并适应TensorFlow的编程范式及其概念体系。本章以TensorFlow Python API层面的概念为基准,首先对比声明式编程与命令式编程各自的特点,给出数据流图在处理机器学习和深度学习问题上的优势。然后介绍TensorFlow数据流图的核心抽象,以及TensorFlow的数据载体、模型载体、运行环境和训练工具等关键模块。最后,借助一元线性回归模型的例子,以最佳实践的形式加深读者对TensorFlow编程范式的理解。

3.1 编程范式:数据流图

作为一个深度学习库,TensorFlow采用了更适合描述深度神经网络模型的声明式编程范式,并以数据流图作为核心抽象。相比使用更广泛的命令式编程范式,基于声明式编程的数据流图的好处有代码可读性强、支持引用透明、提供预编译优化能力等,这些都有助于用户定义数学函数或算法模型。本节主要介绍声明式编程与命令式编程各自的特点,以及基于声明式编程的数据流图在深度学习应用上的独特优势。在本节最后,我们通过实例逐步引出TensorFlow数据流图的基本概念。

3.1.1 声明式编程与命令式编程

计算机科学的研究者将编程语言或函数库为开发人员提供的程序设计基本风格和典型模式定义为编程范式。声明式编程与命令式编程是两种常见的编程范式,它们的最大区别在于:前者强调“做什么”,后者强调“怎么做”。二者各自具有显著的特点。

  • 声明式编程:结构化、抽象化,用户不必纠结每个步骤的具体实现,而是通过下定义的方式描述期望达到的状态。
  • 命令式编程:过程化、具体化,用户告诉机器怎么做,机器按照用户的指示一步步执行命令,并转换到最终的停止状态。

通常,我们认为声明式编程起源于上世纪中叶的人工智能研究,它包括函数式编程(functional programming,简称FP)和逻辑式编程(logic programming,简称LP)等子范式。函数式编程将计算描述为对数学函数的求值,通过lambda演算精确表达计算逻辑;逻辑式编程基于一系列事实和规则,通过逻辑推导得出结论。声明式编程比较接近人的思考模式:程序中的变量代表数学中的抽象符号,而不是某一块内存地址;用户将计算过程抽象为函数表达式,将程序的输出定义为函数值。声明式程序按照用户定义的函数对输入数据进行表达式变换和计算,程序最终的输出仅依赖于用户的输入数据。计算过程既不受内部状态影响,也不受外部环境影响。后面介绍声明式编程时会以函数式编程为主。

命令式编程起源于对汇编语言和机器指令的进一步抽象,本身带有明显的硬件结构特征。它通过修改存储器的值、产生副作用的方式实现计算。这里的“副作用”是指一个函数或表达式除了返回值之外,还对外部环境产生附加的影响,例如修改了函数作用域外的变量或输入参数。命令式程序具有内部状态,计算的过程就是状态转换的过程,改变状态的方式就是对存储器中的变量进行赋值操作。

编程是一种输入到输出的转化机制,这两种编程范式提供了截然不同的解决方案。

  • 声明式编程:程序是一个数学模型,输入是自变量,输出是因变量,用户设计和组合一系列函数,通过表达式变换实现计算。
  • 命令式编程:程序是一个有穷自动机,输入是起始状态,输出是结束状态,用户设计一系列指令,通过指令的执行完成状态转换。

在不设任何前提条件时,探讨两种编程范式的优劣是没有意义的。两种编程范式没有高下之分,只有左右之别,它们的特点决定了各自擅长的领域。

  • 声明式编程:擅长基于数理逻辑的应用领域,如深度学习、人工智能、符号计算系统等。
  • 命令式编程:擅长复杂业务逻辑的应用领域,如交互式UI程序、操作系统与实用工具软件等。

综上,我们将声明式编程和命令式编程的对比总结成表3-1。

表3-1 声明式编程与命令式编程的多角度对比

编程范式

核心思想

实现方法

程序抽象

计算过程

计算单元

变量意义

擅长领域

典型应用

声明式编程

做什么

结构化、抽象化

数学模型

表达式变换

函数

抽象符号

数理逻辑

深度学习

命令式编程

怎么做

过程化、具体化

有穷自动机

状态转换

指令

存储单元

业务逻辑

交互式UI程序

3.1.2 声明式编程在深度学习应用上的优势

现有的深度学习系统大多推荐使用Python等解释型语言开发应用程序,这类语言普遍对声明式和命令式两种编程范式提供良好的支持。下面我们以解释型语言为例,介绍声明式编程在深度学习应用上的优势。虽然C++ 等编译型语言的命令式编程范式也可以通过编译时优化达到其中部分效果,但它们不是深度学习应用开发的主流语言。

对于深度学习系统中以数据流图为主要抽象的编程方式,声明式编程的优势主要包括三点:代码可读性强、支持引用透明和提供预编译优化能力。下面分别展开说明。

  1. 代码可读性强

    通常,声明式编程范式写出来的代码可读性更强。它以目标而非过程为导向,更接近于数学公式或人类的思维方式。用户想要编写一个较为复杂的算法时,声明式代码一目了然,但命令式代码就会比较烦琐,不容易直观地看出其功能。下面我们以使用Python语言计算斐波那契(Fibonacci)数为例,说明两种编程范式的差别。

    斐波那契数F_n的定义为:F_1=1F_2=1F_n=F_{n-1}+F_{n-2}n>2)。使用命令式编程范式求解斐波那契数的典型写法如下:

    def fib(n):
      a, b = 1, 1
      for i in range(1, n):
        a, b = b, a + b
      return a

    读者可能需要逐行分析代码,才能够将其同斐波那契数的定义关联起来。

    现在将其改写为声明式编程范式的代码,如下所示:

    fib = lambda x : 1 if x <= 2 else fib(x - 1) + fib(x - 2)

    我们可以使用lambda表达式语法在一行内完成函数定义。这段代码不涉及算法实现细节,更加接近自然语言的表达形式,读者很容易理解它的实际功能。

    当然,细心的读者可能会指出,这段声明式代码涉及函数递归调用,性能较差。对于Python解释器这种简单的运行时环境,递归调用的性能问题确实存在。但对于专门为函数式编程设计的开发库和运行时环境,一般都会有多种编译优化机制来处理递归展开等问题,这可以自动改善性能。TensorFlow的数据流图运行时框架也不例外。TensorFlow推荐使用声明式编程范式创建深度神经网络模型。通过调用丰富的内置抽象,可以让网络易于设计且具有良好的层次感。模型代码的可读性因此得到增强,从而缩短了理解程序的时间。

  2. 支持引用透明

    函数的副作用会给程序设计带来不必要的麻烦,有可能引入难以查找的错误,并降低程序的可读性。引用透明(referential transparency)的概念与函数的副作用相关,且受其影响。如果一个函数的语义同它出现在程序中的上下文无关,则称它是引用透明的。对于用于表达算法的函数,引用透明的一个推论是函数的调用语句可以被它的返回值取代,而不影响程序语义。

    声明式编程没有内部状态,也不依赖于外部环境;输出结果由输入数据唯一确定,与代码上下文无关。用户创建的模型就是一幅数据流图,图的拓扑结构由函数的组合关系定义。每一个函数都是一个计算单元或模块,对应着图中的一个节点。用户可以选择执行任意的模块组合(子图),以得到不同模型结构的输出结果。同时,用户也可以反复执行同样的模型结构,通过输入不同的数据,得到不同的输出结果,以此实现神经网络训练等迭代计算逻辑。这种设计能够简化基于梯度下降法的深度神经网络模型的训练步骤,因为训练模型就是在相同的模型结构上输入不同的批数据,然后反复计算得到各参数梯度并不断更新模型参数。

    可以看出,引用透明的函数更符合数学语言中对函数的定义,而不再像计算机语言中函数概念的原始定义——子程序。因此,引用透明的特性对于实现数学算法是非常友好的。TensorFlow和类似的深度学习库内置了大量的数学函数,如代数计算、数组计算、归约计算、卷积计算、神经网络及图像处理函数等。用户一般不需要从头开始制造轮子,而是通过组合内置的函数,创建自己的算法模型。声明式编程将函数视为与其他数据类型一样的“一等公民”,一个函数本身可以以“闭包”(closure)的形式成为另一个函数的输入,而非像命令式编程那样以函数的输入、输出参数的方式传输状态值。这样一来,程序在正确性保证、运行时优化等方面比命令式实现更具优势。

  3. 提供预编译优化能力

    对于以数据流图为核心抽象的声明式编程语言或函数库,其运行时环境不像解释型命令式语言那样即刻执行代码,而是类似于编译型命令式语言的语法树生成过程,需要事先编译得到完整的数据流图,然后根据用户选择的子图,输入数据进行计算。因此,声明式编程能够实现多种预编译优化,包括无依赖逻辑并行化、无效逻辑移除、公共逻辑提取、细粒度操作融合等。这里我们以无依赖逻辑并行化为例,说明声明式编程如何提供预编译优化能力。下面的伪代码定义了模型E=(A+B)*(C-D)

    A = Variable('A')
    B = Variable('B')
    C = Variable('C')
    D = Variable('D')
    E = multiply(add(A, B), subtract(C, D))
    f = compile(E)
    e = f(A=3, B=2, C=4, D=2)  # e = 10

    图3-1描绘了执行预编译函数f = compile(E)后,程序获取到的计算E的完整数据流图。

    图3-1 E=(A+B)*(C-D)的数据流图示例

    如果一个函数的输入值是另一个函数的输出值,则认为前者依赖于后者,对应在数据流图中就是一条后者指向前者的有向边。运行时库获取到整幅数据流图后,能够清楚地理解各个输入、输出之间的依赖关系,故而容易找到可以并行计算的节点。本例中,A+BC-D之间没有任何依赖关系,因此图上没有对应的有向边,它们可以并行执行。然而,E必须等待A+BC-D计算完成后,才能计算。通过预编译优化技术,TensorFlow和类似的深度学习库可以在运行时有效提升算法并行度,在多核CPU与GPU场景下加快程序的运行速度。

3.1.3 TensorFlow数据流图的基本概念

TensorFlow将数据流图明确地定义为:用节点和有向边描述数学运算的有向无环图。如图3-2所示,数据流图中的节点通常代表各类操作(operation),具体包括数学运算、数据填充、结果输出和变量读写等操作,每个节点上的操作都需要分配到具体的物理设备(如CPU、GPU)上执行。图中的有向边描述了节点间的输入、输出关系,边上流动(flow)着代表高维数据的张量(tensor),这就是TensorFlow名称的由来。

图3-2 TensorFlow的数据流图示例

基于梯度下降法优化求解的机器学习问题,通常都可以分为前向图求值与后向图求梯度两个计算阶段。其中,前向图由用户编写代码完成,主要过程包括定义模型的目标函数(object function)和损失函数(loss function),输入、输出数据的形状(shape)、类型(dtype)等;后向图由TensorFlow的优化器(optimizer)自动生成,主要功能是计算模型参数的梯度值,并使用梯度值更新对应的模型参数。

下面分别介绍数据流图中的主要概念,以及数据流图和TensorFlow会话的执行原理。

  1. 节点

    前向图中的节点统一称为操作,它们根据功能可以分为以下3类。

    • 数学函数或表达式:比如图3-2中的MatMul、BiasAdd和Softmax,绝大多数节点都属于此类。
    • 存储模型参数的变量(variable):比如图3-2中ReLu Layer中的W_{{\rm h1}}b
    • 占位符(placeholder):比如图3-2中的Input和Class Labels,它们通常用来描述输入、输出数据的类型和形状等,便于用户利用数据的抽象结构直接定义模型。在数据流图执行时,占位符需要填充对应的数据。

    后向图中的节点同样分为以下三类。

    • 梯度值:即经过前向图计算出的模型参数的梯度,比如图3-2中的Gradients。
    • 更新模型参数的操作:比如图3-2中的Update W和Update b,它们定义了如何将梯度值更新到对应的模型参数。
    • 更新后的模型参数:比如图3-2中SGD Trainer内的Wb,与前向图中的模型参数一一对应,但参数值得到了更新,用于模型的下一轮训练。
  2. 有向边

    数据流图中的有向边用于定义操作之间的关系,它们分为两类:一类用来传输数据,绝大部分流动着张量的边都是此类,在图3-2中用实线表示,简称数据边。另一类用来定义控制依赖(control dependency),通过设定节点的前置依赖决定相关节点的执行顺序,在图3-2中用虚线表示,简称控制边。

    所有的节点都通过数据边和控制边连接。入度为0的节点没有前置依赖,可以立即执行;入度非0的节点需要等待所有依赖节点执行结束后,方可执行。

  3. 执行原理

    声明式编程的特点决定了在深度神经网络模型的数据流图上,各个节点的执行顺序并不完全依赖于代码中定义的顺序,而是与节点之间的逻辑关系以及运行时库的实现机制相关。在使用数据边和控制边描述节点依赖关系的基础上,TensorFlow设计了一套精妙而有序的执行机制来确保数据流图正确执行。这里我们抛开运行时库内部的复杂实现,仅从高层宏观视角审视数据流图的执行原理。简单来说,数据流图上节点的执行顺序的实现参考了拓扑排序的设计思想。当用户使用TensorFlow执行指定数据流图时,其过程可以简述为以下4个步骤。

    (1) 以节点名称作为关键字、入度作为值,创建一张散列表,并将此数据流图上的所有节点放入散列表中。

    (2) 为此数据流图创建一个可执行节点队列,将散列表中入度为0的节点加入到该队列,并从散列表中删除这些节点。

    (3) 依次执行该队列中的每一个节点,执行成功后将此节点输出指向的节点的入度值减1,更新散列表中对应节点的入度值。

    (4) 重复步骤 (2) 和步骤 (3),直到可执行节点队列变为空。

    下面以图3-2所示的数据流图执行过程为例说明。最初可执行节点队列中只有Input节点。执行Input后,Reshape和Class Labels节点的入度减为0,加入可执行节点队列。同时,这两个节点将从散列表中移除。接下来,程序将执行Reshape和Class Labels节点。以此类推,直到可执行节点队列变为空。

    TensorFlow数据流图本身是一个有向无环图,程序结果的正确性依赖于图上节点的执行顺序。通过这套数据流图执行机制,TensorFlow能够支持复杂、多样化的算法模型。

3.2 数据载体:张量

张量广泛应用于物理学、数学和工程学中。在不同的应用领域,张量具有不同的学术定义。这里援引维基百科的解释:张量是用来表示一些矢量、标量和其他张量之间线性关系的多线性函数,这些线性关系的典型例子有内积、外积、线性映射以及笛卡儿积等。张量的抽象理论是线性代数的分支:多重线性代数。

在TensorFlow中,张量是数据流图上的数据载体。为了更方便地定义数学表达式、更准确地描述数学模型,TensorFlow使用张量统一表示所有数据。在实际计算时,即表达式的转换过程中,模型所对应的表达式中的数据由张量来承载。TensorFlow提供TensorSparseTensor两种张量抽象,分别表示稠密数据和稀疏数据。后者旨在减少高维稀疏数据的内存占用。

3.2.1 张量:Tensor

在数学中,张量是一种几何实体,广义上可表示任意形式的数据。表3-2列出了张量与常见的数据实体的关系,图3-3给出了对应的数据实体实例。用于承载数据的张量可以理解为0阶标量、1阶向量和2阶矩阵在高维空间上的推广,张量的阶(rank)表示它所描述数据的最大维度。在NumPy等数学计算库或TensorFlow等深度学习库中,我们通常使用多维数组的形式描述一个张量,数组的维数表示对应张量的阶数。

表3-2 张量与常见数据实体的关系

数据实体

Python样例

0

标量

scalar = 1

1

向量

vector = [1, 2, 3]

2

矩阵(数据表)

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

3

数据立方

tensor = [[[1, 2, 3], [4, 5, 6], [7, 8, 9]], [[10, 11, 12], [13, 14, 15], [16, 17, 18]]]

n

n阶张量

...

图3-3 表3-1中Python样例对应的数据实体示例

张量的阶数决定了其描述的数据所在高维空间的维数。在此基础上,定义每一阶的长度可以唯一确定一个张量的形状。TensorFlow中的张量形状用列表表示,列表中的每个值依次表示张量各阶的长度。例如,图3-3中3阶张量各阶的长度分别为D_0=2D_1=3D_2=3,因此它的形状为[2,3,3]。执行数据流图上的操作时,需要保证同一操作下的张量形状符合计算规则。当用户没有显式设置输出张量的形状时,TensorFlow内部会根据操作的输入张量进行形状推理(shape inference),以确保操作能够正确执行。

TensorFlow的张量具有极强的数据表达能力,这既体现在它对高维数据的抽象描述,又体现在它对多样化数据类型的支持。表3-3罗列了TensorFlow张量支持的数据类型。除了支持常用的浮点数、整数、字符串、布尔型等类型外,也支持复数和量化整数类型。用户可以在tensorflow/python/framework/dtypes.py文件中找到相关的代码定义和说明。

表3-3 TensorFlow张量支持的数据类型

Dtype对象

TensorFlow数据类型

说明

DT_HALF

tf.float16

半精度浮点数

DT_FLOAT

tf.float32

单精度浮点数

DT_DOUBLE

tf.float64

双精度浮点数

DT_BFLOAT16

tf.bfloat16

裁短浮点数

DT_INT8

tf.int8

8位有符号整数

DT_INT16

tf.int16

16位有符号整数

DT_INT32

tf.int32

32位有符号整数

DT_INT64

tf.int64

64位有符号整数

DT_UINT8

tf.uint8

8位无符号整数

DT_UINT16

tf.uint16

16位无符号整数

DT_STRING

tf.string

字符串

DT_BOOL

tf.bool

布尔值

DT_COMPLEX64

tf.complex64

单精度复数(实部和虚部均为单精度浮点数)

DT_COMPLEX128

tf.complex128

双精度复数(实部和虚部均为双精度浮点数)

DT_QINT8

tf.qint8

量化的8位有符号整数

DT_QINT16

tf.qint16

量化的16位有符号整数

DT_QINT32

tf.qint32

量化的32位有符号整数

DT_QUINT8

tf.quint8

量化的8位无符号整数

DT_QUINT16

tf.quint16

量化的16位无符号整数

TensorFlow的张量在逻辑定义上是数据载体,但在物理实现时是一个句柄,它存储张量的元信息以及指向张量数据的内存缓冲区指针。这样设计是为了实现内存复用。在某些前置操作(生产者)的输出值被输入到多个后置操作(消费者)的情况下,无须重复存储输出值。当一个张量不再被任何操作依赖后,TensorFlow会释放存储该张量的内存缓冲区。例如,图3-1中的A*BC-D的计算结果被乘法操作处理之后,存储它们的内存将会被释放。TensorFlow内部通过引用计数方式判断是否应该释放张量数据的内存缓冲区,这一机制类似于编程语言中的垃圾回收机制。

  1. 创建

    在TensorFlow Python API中,稠密张量抽象是Tensor类,它定义在tensorflow/python/framework/ops.py文件里。表3-4列出了Tensor构造方法的完整输入参数,这些参数同时也是张量的属性。

    表3-4 TensorFlow张量的属性

    属性名称功能说明
    dtype张量传输数据的类型
    name张量在数据流图中的名称
    graph张量所属的数据流图
    op生成该张量的前置操作
    shape张量传输数据的形状
    value_index张量在该前置操作所有输出值中的索引

    不过,在一般情况下,用户不需要使用Tensor类的构造方法直接创建张量,而是通过操作间接创建张量。典型的张量创建操作包括常量定义操作和代数计算操作。下面的代码给出了使用constantadd操作创建张量abc的示例:

    import tensorflow as tf
    
    a = tf.constant(1.0)
    b = tf.constant(2.0)
    c = tf.add(a, b)
    print([a, b, c])
    '''
    输出:
    [<tf.Tensor 'Const:0' shape=() dtype=float32>,
     <tf.Tensor 'Const_1:0' shape=() dtype=float32>,
     <tf.Tensor 'Add:0' shape=() dtype=float32>]
    '''
  2. 求解

    数据流图中的操作输出值由张量承载。如果用户想要求解特定张量的值,则需要创建会话,然后执行张量的eval方法或会话的run方法。下面的代码给出了获取张量abc值的示例:

    with tf.Session() as sess:
      print(c.eval())
      print(sess.run([a, b, c]))
    '''
    输出:
    3.0
    [1.0, 2.0, 3.0]
    '''
  3. 成员方法

    TensorFlow的Tensor抽象除了支持多样化的数据类型外,也提供一些成员方法来动态改变张量形状,以及查看张量的后置操作。表3-5给出了TensorFlow张量的公共成员方法。

    表3-5 TensorFlow张量的公共成员方法

    方法名称功能说明
    eval取出张量值
    get_shape获取张量的形状
    set_shape修改张量的形状
    consumers获取张量的后置操作
  4. 操作

    TensorFlow为张量提供了大量操作,以便构建数据流图,实现算法模型。典型的操作如表3-6所示。下一节将详细介绍其中的重要操作。

    表3-6 TensorFlow针对张量提供的典型操作

    操作类型典型操作
    一元代数操作absneginvert
    二元代数操作addmultiplysub
    形状操作chipreshapesliceshuffle
    归约操作reduce_meanreduce_sum
    神经网络操作convpoolsoftmaxrelu
    条件操作cond
  5. 典型用例

    下面通过一组典型的用例展示张量的创建、求解,以及其他成员方法的使用。示例代码和运行输出如下所示:

    import tensorflow as tf
    
    a = tf.constant([1, 1])
    b = tf.constant([2, 2])
    c = tf.add(a, b)
    with tf.Session() as sess:
      print("a[0]=%s, a[1]=%s" % (a[0].eval(), a[1].eval()))
      print("c.name=%s" % c.name)
      print("c.value=%s" % c.eval())
      print("c.shape=%s" % c.shape)
      print("a.consumers=%s" % a.consumers())
      print("b.consumers=%s" % b.consumers())
      print("[c.op]:\n%s" % c.op)
    '''
    输出:
    a[0]=1, a[1]=1    # 可以通过下标获取张量的特定部分
    c.name=Add:0
    c.value=[3 3]
    c.shape=(2,)
    # 张量a和b的后置操作均为add
    a.consumers=[<tf.Operation 'Add' type=Add>]
    b.consumers=[<tf.Operation 'Add' type=Add>]
    # add操作(即用于生成张量c的操作)的属性如下
    [c.op]:
    name: "Add"
    op: "Add"
    input: "Const"    # 一个输入值为生成张量a的const操作
    input: "Const_1"  # 另一个输入值为生成张量b的const_1操作
    attr {
      key: "T"
      value {
        type: DT_INT32
      }
    }
    '''

3.2.2 稀疏张量:SparseTensor

TensorFlow提供了专门用于处理高维稀疏数据的SparseTensor类。该类以键值对的形式表示高维稀疏数据,它包含indicesvaluesdense_shape这3个属性。其中,indices是一个形状为 [N, ndims]Tensor实例,N表示非零元素的个数,ndims表示张量的阶数。例如,当indices=[[0, 2], [1, 3]]N=2, ndims=2)时,表示2阶稀疏张量中索引为 [0, 2] 和 [1, 3] 的元素非零。values是一个形状为 [N]Tensor对象,用于保存indices中指定的非零元素。dense_shape是一个形状为 [ndims]Tensor实例,表示该稀疏张量对应稠密张量的形状。

  1. 创建

    在TensorFlow中创建稀疏张量时,一般可以直接使用SparseTensor类的构造方法。示例代码如下:

    import tensorflow as tf
    
    sp = tf.SparseTensor(indices=[[0, 2], [1, 3]], values=[1, 2], dense_shape=[3, 4])
    '''
    稀疏张量sp的键值对形式为:
    [0, 2]: 1
    [1, 3]: 2
    
    等价于形状为[3, 4]的2阶稠密张量:
      [[0, 0, 1, 0]
       [0, 0, 0, 2]
       [0, 0, 0, 0]]
    '''
    
    with tf.Session() as sess:
      # 可以看出,SparseTensor实例本身由3个Tensor实例组成
      print(sp.eval())
    '''
    输出:
    SparseTensorValue(indices=array([[0, 2], [1, 3]]),
                      values=array([1, 2], dtype=int32),
                      dense_shape=array([3, 4]))
    '''
  2. 操作

    TensorFlow为稀疏张量提供了一些专门的操作,这样用户能够像处理稠密张量那样处理稀疏张量。典型的操作如表3-7所示。

    表3-7 TensorFlow针对稀疏张量提供的典型操作

    操作类型典型操作
    转换操作sparse_to_densesparse_to_indicatorsparse_merge
    代数操作sparse_addsparse_softmaxsparse_tensor_dense_matmulsparse_maximum
    几何操作sparse_concatsparse_reordersparse_splitsparse_transpose
    归约操作sparse_reduce_sumsparse_reduce_sum_sparse
  3. 典型用例

    下面我们通过一组典型的用例展示稀疏张量的创建,以及归约操作的调用方法。示例代码和运行输出如下所示:

    import tensorflow as tf
    
    x = tf.SparseTensor(indices=[[0,0], [0,2], [1,1]], values=[1,1,1], dense_shape=[2,3])
    # 稀疏张量对应的稠密张量为[[1, 0, 1], [0, 1, 0]]
    reduce_x = [tf.sparse_reduce_sum(x),                         # => 3
                tf.sparse_reduce_sum(x, axis=1),                 # => [2, 1]
                tf.sparse_reduce_sum(x, axis=1, keep_dims=True), # => [[2], [1]]
                tf.sparse_reduce_sum(x, axis=[0, 1])]            # => 3
    with tf.Session() as sess:
      print(sess.run(reduce_x))
    '''
    输出:
    [3,
     array([2, 1], dtype=int32),
     array([[2], [1]], dtype=int32),
     3]
    '''

3.3 模型载体:操作

TensorFlow的算法模型由数据流图表示,数据流图由节点和有向边组成,每个节点均对应一个具体的操作。因此,操作是模型功能的实际载体。数据流图中的节点按照功能不同可以分为以下3种。

  • 计算节点:对应的是无状态的计算或控制操作,主要负责算法逻辑表达或流程控制。
  • 存储节点:对应的是有状态的变量操作,通常用来存储模型参数。
  • 数据节点:对应的是特殊的占位符操作,用于描述待输入数据的属性。

本节中,我们将依次介绍TensorFlow中3种节点及其对应操作的定义、功能和典型使用方法。

3.3.1 计算节点:Operation

计算节点对应的计算操作抽象是Operation类。计算节点的入边代表输入张量,出边代表输出张量。每个节点对输入张量进行特定的数学运算或流程控制,然后将结果输出到后置的节点。Operation类定义在tensorflow/python/framework/ops.py文件中,它提供获取操作的名称、类型、输入张量、输出张量等基本属性的方法。表3-8列出了计算操作的主要属性。

表3-8 计算操作的主要属性

属性名称

功能说明

name

操作在数据流图中的名称

type

操作的类型名称

inputs

输入张量列表

control_inputs

输入控制依赖列表

outputs

输出张量列表

device

操作执行时使用的设备

graph

操作所属的数据流图

traceback

操作实例化时的调用栈

用户在编写数据流图的算法逻辑时,通常不需要显式构造Operation实例,只需要使用TensorFlow提供的各种操作函数来定义计算节点。这些函数执行后,TensorFlow内部会自动构造相应的Operation实例。这些操作函数一般定义在tensorflow/python/ops目录下的各个文件。表3-9分类整理了TensorFlow Python API提供的典型操作。

表3-9 TensorFlow Python API提供的典型操作

操作类型

典型操作

基础算术

addmultiplymodsqrtsintracefftargmin

数组运算

sizeranksplitreversecastone_hotquantize

梯度裁剪

clip_by_valueclip_by_normclip_by_global_norm

逻辑控制和调试

identitylogical_andequallessis_finiteis_nan

数据流控制

enqueuedequeuesizetake_gradapply_grad

初始化操作

zeros_initializerrandom_normal_initializerorthogonal_initializer

神经网络运算

convolutionpoolbias_addsoftmaxdropouterosion2d

随机运算

random_normalrandom_shufflemultinomialrandom_gamma

字符串运算

string_to_hash_bucketreduce_joinsubstrencode_base64

图像处理运算

encode_pngresize_imagesrot90hsv_to_rgbadjust_gamma

用户可以在Python解释器中查看操作函数所属的命名空间,进而推断其所在的Python文件。方法如下:

>>> import tensorflow as tf
>>> help(tf.reshape)
Help on function reshape in module tensorflow.python.ops.gen_array_ops:
...
>>> help(tf.matmul)
Help on function matmul in module tensorflow.python.ops.math_ops:
...
>>> help(tf.add)
Help on function add in module tensorflow.python.ops.gen_math_ops:
...

命名空间带有gen_ 前缀的操作一般由C++ 核心层实现,并在TensorFlow编译时生成对应的gen_*.py文件。因此,在TensorFlow项目源代码目录中,无法找到这些Python文件。感兴趣的读者可以在TensorFlow安装之后的Python site-packages(或dist-packages)软件包目录下找到对应的文件。

下面我们以c=a+b中的add操作为例,说明计算操作的执行过程和实现原理。示例代码如下:

import tensorflow as tf
# 创建名字作用域AddExample
with tf.name_scope("AddExample"):
  # 创建变量a和b
  a = tf.Variable(1.0, name='a')
  b = tf.Variable(2.0, name='b')
  # 使用add操作函数创建张量c
  c = tf.add(a, b, name='add')
  print(c)
'''
输出:
Tensor("add:0", shape=(), dtype=float32) # 张量c的属性
'''

我们能够成功打印输出张量c的属性,但是无法直接观察到内部自动构造的add操作的属性。事实上,这个操作的一部分属性在代码中已经显式体现出来了:操作名称为add,操作类型也为add;输入张量是ab,它们的值分别为1.0和2.0;输出张量是c;没有控制依赖输入。为了观察代码中未体现的细节,TensorFlow为用户提供了查看数据流图的可视化工具——TensorBoard。我们使用TensorBoard将c=a+b对应的数据流图渲染了出来,如图3-4所示。第6章将详细介绍TensorBoard的使用方法,现在让我们将目光暂时聚焦到TensorFlow计算操作。

{%}

图3-4 使用TensorBoard可视化c=a+b的数据流图(左)和add操作的属性列表(右)

读者不妨基于图3-4思考以下3个问题。

(1) 变量ab是如何转换为标量(scalar)并传输给add操作的?

(2) 张量和标量之间是什么关系?

(3) 在add操作属性列表中,Inputs下的AddExample/a/read是什么意思?

在图3-4所示的例子中,左图被选中的节点对应的是add操作,它的两条输入边上流动的数据的类型均为scalar。与add节点相连的a节点保存着变量a,展开a节点,可得到如图3-5所示的变量a的内部结构(下面简称a子图)。可以看出,变量a其实是由 (a)、Assign、read和initial_value这四个子节点组合而成。当执行c = tf.add(a, b, name='add') 时,add操作通过调用a子图中的read子节点,将变量a转换为标量传输给add操作。上节提到过,标量是一种特殊的张量,即0阶张量,所以本质上add节点的输入边上流动的仍然是张量表示的数据。看到这里,AddExample/a/read的含义也就明确了,它表达了一个输入节点的层次结构:AddExample是名字作用域,a是作用域中的变量,而read是a内部的子节点。

{%}

图3-5 c=a+b的数据流图局部展开(左)和a子图的属性列表(右)

在数据流图计算开始之前,用户通常需要执行tf.global_variables_initializer函数来进行全局变量的初始化。其本质就是将initial_value传入Assign子节点,实现对变量的初次赋值。图3-6展示了本例中initial_value子节点的属性列表。

{%}

图3-6 c=a+b的数据流图局部展开(左)和initial_value子节点的属性列表(右)

3.3.2 存储节点:Variable

存储节点作为数据流图中的有状态节点,其主要作用是在多次执行相同数据流图时存储特定的参数,如深度学习或机器学习的模型参数。对于无状态节点,其输出由输入张量和节点操作共同确定。对于有状态的节点,如存储节点,其输出还会受到节点内部保存的状态值影响。

  1. 变量

    TensorFlow数据流图上的存储节点抽象是Variable类,我们通常称其为变量。如表3-10所示,Variable类提供了变量的名称、数据类型、形状、初始值和初始化操作等属性。感兴趣的读者可以在tensorflow/python/ops/variables.py文件中找到Variable类的定义。

    表3-10 TensorFlow变量的主要属性

    属性名称功能说明
    name变量在数据流图中的名称
    dtype变量的数据类型
    shape变量的形状
    initial_value变量的初始值
    initializer计算前为变量赋值的初始化操作
    device存储变量的设备
    graph变量所属的数据流图
    op变量操作

    如图3-5所示,作为存储节点的变量不是一个简单的节点,而是一幅由多个子节点构成的子图。一个变量通常由如下四种子节点构成:

    • 变量初始值;
    • 更新变量值的操作;
    • 读取变量值的操作;
    • 变量操作。

    在本例的a子图中,它们分别对应inital_value、Assign、read和 (a) 节点。其中,前三种节点对应的都是无状态操作,而变量操作节点对应的是有状态操作。为了便于区分,TensorBoard在渲染数据流图时,为有状态操作添加了一对括号。本节的重点是理清变量和变量操作的区别。关于变量的具体使用方法,我们将在4.2节中专门介绍。

  2. 变量操作

    变量操作是TensorFlow中的一类有状态操作,用于存储变量的值。变量操作对应的操作函数是tensorflow/python/ops/state_ops.py文件中定义的variable_op_v2,其代码如下所示:

    def variable_op_v2(shape, dtype, name="Variable", container="", shared_name=""):
      """创建变量操作"""
      return gen_state_ops._variable_v2(shape=shape,
                                        dtype=dtype,
                                        name=name,
                                        container=container,
                                        shared_name=shared_name)

    构造变量操作时,需要给定其存储变量的形状与数据类型。

    每个变量对应的变量操作对象在变量初始化时构造。变量支持两种初始化方式。

    • 初始值。用户输入初始值完成初始化。如果没有显式指定初始值,TensorFlow会根据变量的数据类型进行默认初始化。
    • VariableDef。用户使用Protocol Buffers定义的变量完成初始化,这通常适用于继续训练时从文件系统中恢复模型参数的场景。

    这两种初始化方式分别实现于Variable类的私有成员方法 _init_from_args_init_from_proto。用户只能选择其中一种方法进行初始化,否则会导致ValueError错误。

    图3-5中的a子图显然属于第一种情况。当我们创建变量a时,_init_from_args方法内部调用了state_ops.variable_op_v2函数,创建了该变量对应的变量操作,并将其存储在Variable实例的私有成员变量 _variable中。下面是从 _init_from_args方法中摘取的相关代码片段:

    class Variable(object):
      def _init_from_args(...):
        ...
        self._variable = state_ops.variable_op_v2(
            shape,
            self._initial_value.dtype.base_dtype,
            name=name)

    在本例中,(a) 节点其实就是变量a的私有成员变量 _variable,即变量a的变量操作。

    图3-7给出了TensorBoard对 (a) 节点属性的解释。可以看到,(a) 节点实际对应VariableV2操作,它拥有四个属性:containerdtypeshapeshared_name。(a) 节点内部存储变量a的值,当用户想要读取或更改a的值时,均需要经由read或Assign节点。

    {%}

    图3-7 c=a+b的数据流图局部展开(左)和 (a) 节点的属性列表(右)

  3. read节点

    现在我们已经理清了变量和变量操作的区别,明白了变量操作可以用来存储变量的值。下面我们通过解释read节点的实现原理,加深读者对于变量、变量操作和变量值的理解。

    Variable类的源代码可以看出,读取变量内部存储的值的成员方法是read_value。该方法在内部调用了identity操作,并将操作节点名称设置为readidentity操作接受一个变量作为输入,并返回当前上下文中变量的值。read_value方法的代码如下所示:

    def read_value(self):
      """Returns the value of this variable, read in the current context."""
        return array_ops.identity(self._variable, name="read")

    图3-8展示了这一过程的内部实现。可以看出,被选中的read节点实际对应的是identity操作。

    {%}

    图3-8 c=a+b的数据流图(左)和read节点的属性列表(右)

    用户无须显式调用变量的read_value方法来获取变量值。在数据流图的计算过程中,需要获取这些变量值的后置操作会在内部自动完成相关调用。综上,当用户执行c = tf.add(a, b, name='add') 时,add操作获取变量a的完整调用栈如图3-9所示。这里的convert_to_tensor方法用于尝试将多种形式的输入转换为张量,因为TensorFlow操作的输入不仅支持张量,同时也支持列表、数组和标量等。

    图3-9 add操作获取变量a的调用栈

    通过上面的分析,我们可以得到如下结论:变量是有状态的节点,其内部的变量操作长期保存变量对应的值。变量值的生命周期与数据流图相同,在数据流图执行过程中始终存在。普通节点没有内部状态,即不长期保存任何值。它们操作的对象是输入、输出边上流动的张量,这些张量都是临时创建的。当依赖它们的所有操作执行完成后,这些节点及临时张量的内存便会被释放。

3.3.3 数据节点:Placeholder

TensorFlow数据流图描述了数学模型的计算拓扑,其中各个操作节点都是抽象的函数映射或数学表达式。换句话说,数据流图本身是一个具有计算拓扑和内部结构的“壳”。在用户向数据流图填充数据前,图中并没有真正执行任何计算。

通常,用户在创建模型时已经明确输入数据的类型和形状等属性,而模型的第一步计算很可能就需要使用这些输入数据。TensorFlow数据节点的作用便是定义待输入数据的属性,使得用户可以描述数据特征,从而完成模型创建。当数据流图执行时,TensorFlow会向数据节点填充(feed)用户提供的、符合定义的数据。TensorFlow内部将填充的数据转换为张量后,按照数据流图的拓扑结构执行计算。因此,在创建模型的阶段,用户不需要向数据流图输入任何数据。但在实际计算时,如果没有填充正确的数据,程序就会出错。

TensorFlow数据节点由占位符操作(placeholder operation)实现,它对应的操作函数是tf.placeholder。针对稀疏数据,TensorFlow亦提供了稀疏占位符操作(sparse placeholder operation),其操作函数是tf.sparse_placeholder。表3-11列出了这两个函数的输入参数。

表3-11 TensorFlow占位符和稀疏占位符操作函数的输入参数

属性名称

功能说明

name

占位符操作在数据流图中的名称

dtype

填充数据的类型

shape

填充数据的形状

典型用例

下面我们分别列举占位符和稀疏占位符操作的典型使用示例,以帮助用户快速掌握它们的使用方法和诀窍。

在下面的代码中,x是由占位操作符创建的2阶张量,其形状为 [2, 2],数据类型为单精度浮点数;ymatmul操作输出的张量,它以x作为输入参数。根据依赖控制,用户需要先填充满足x定义的张量,才能使得y开始执行。然而,这段代码没有为x填充数据,所以TensorFlow会抛出参数不可用错误(InvalidArgumentError),提示用户为x填充符合占位符约束的张量:

import tensorflow as tf
import numpy as np

with tf.name_scope("PlaceholderExample"):
  # 定义形状为[2,2]的单精度浮点数矩阵
  x = tf.placeholder(tf.float32, shape=(2, 2), name="x")
  y = tf.matmul(x, x, name="matmul")

with tf.Session() as sess:
  # 直接执行matmul操作获取张量y的值时,会报错,因为没有为x填充数据
  print(sess.run(y))
'''输出:
InvalidArgumentError : You must feed a value for placeholder tensor 'PlaceholderExample/x' with dtype float and shape [2,2]
'''

图3-10是使用TensorBoard可视化上面代码所得数据流图的结果。从选中的matmul节点的属性来看,该节点依赖于输入张量x。如果用户想要正确执行matmul节点,那么必须向x填充数据。

{%}

图3-10 使用TensorBoard可视化y=matmul(x, x) 所得的数据流图(左)和matmul节点的属性列表(右)

为了解决输入参数不可用的问题,一种方法是调用NumPy库的随机数生成方法np.random.rand(默认的数据类型为单精度浮点数),并通过Session.run方法的feed_dict参数将生成的随机数填充到x。这样一来,程序便可以正确执行,并输出计算结果。需要修改的代码如下所示:

with tf.Session() as sess:
  # 将符合定义的数据填充进x后,正确执行
  rand_array = np.random.rand(2, 2)
  print(sess.run(y, feed_dict={x: rand_array}))
'''
输出:
[[ 0.27517948  0.1988095 ]
 [ 0.07253421  0.37904623]]
'''

最后,我们给出一段稀疏占位符操作的典型用例:

import tensorflow as tf
import numpy as np

# 没有显式指定数据形状,表示可以填充任意形状的单精度浮点数稀疏张量
x = tf.sparse_placeholder(tf.float32)
y = tf.sparse_reduce_sum(x)

with tf.Session() as sess:
  # 设置非零元素的索引为[3, 2, 0]和[4, 5, 1]
  indices = np.array([[3, 2, 0], [4, 5, 1]], dtype=np.int64)
  # 设置索引为[3, 2, 0]和[4, 5, 1]元素的值分别为1.0和2.0
  values = np.array([1.0, 2.0], dtype=np.float32)
  # 设置稀疏张量对应的稠密张量形状为[7, 9, 2]
  shape = np.array([7, 9, 2], dtype=np.int64)
  # 向x填充稀疏张量
  print(sess.run(y, feed_dict={
    x: tf.SparseTensorValue(indices, values, shape)}))
  # 向x填充张量3元组(indices, values, shape)
  print(sess.run(y, feed_dict={x: (indices, values, shape)}))
  # 向x填充NumPy多维数组
  sp = tf.SparseTensor(indices=indices, values=values, dense_shape=shape)
  sp_value = sp.eval()
  print(sess.run(y, feed_dict={x: sp_value}))
'''
输出:
3.0
3.0
3.0
'''

在该例中,x是稀疏占位符操作返回的稀疏张量,其元素数据类型为单精度浮点数,形状未定。y是对x中的所有元素进行归约求和计算的结果。这里定义了3个NumPy数组——indicesvaluesshape,分别表示稀疏张量中非零元素的索引、值,以及对应的稠密张量形状。我们以3种不同的格式向x填充数据,并执行数据流图,程序均能输出符合预期的结果。

3.4 运行环境:会话

TensorFlow数据流图描述了计算的拓扑结构和所需的数据属性,但数据流图本身仅是一个“壳”。只有在图中填充了数据、选择了待求解的张量,并执行了相应的计算操作后,才能取得最终结果。TensorFlow会话为用户提供了上述计算过程的运行环境,它本质上是在维护一段运行时上下文。会话通过提取和切分数据流图、调度并执行操作节点,将抽象的计算拓扑转化为设备上的执行流,从而帮助用户完成计算任务。本节主要介绍TensorFlow会话的定义和使用方法,并对比普通会话和交互式会话的异同。

3.4.1 普通会话:Session

TensorFlow会话提供求解张量和执行操作的运行环境。它是发放计算任务的客户端,所有计算任务都由它分发到其连接的执行引擎完成。在Python API中,会话由tensorflow/python/ client/session.py文件定义的Session类实现。一个会话的典型使用流程分为3步:(1) 创建会话;(2) 运行会话;(3) 关闭会话。框架性代码如下所示:

sess = tf.Session() # (1)创建会话
sess.run(...)       # (2)运行会话
sess.close()        # (3)关闭会话

下面我们分别介绍会话的这3步典型使用流程。

  1. 创建会话

    会话为TensorFlow数据流图提供运行环境,用户一般通过调用Session类的构造方法来创建会话实例。一个典型的会话实例具有3个主要属性:会话连接的执行引擎、加载的数据流图及其启动配置项。如表3-12所示,这3个属性均是Session构造方法的输入参数。

    表3-12 Session构造方法的输入参数

    参数名称功能说明
    target会话连接的执行引擎
    graph会话加载的数据流图
    config会话启动时的配置项

    target参数的默认值指向进程内引擎(in-process engine),因此用户执行单机任务时无需显式指定它。本节仅分析单机运行的场景。分布式运行模式涉及TensorFlow集群和分布式会话的配置,我们将在5.2节中专门介绍。graph参数的默认值指向当前代码中的唯一一幅默认数据流图。当用户没有设置graph参数时,会话加载默认数据流图;当用户在代码中定义了多幅数据流图时,则需要通过graph显式指定待加载的数据流图。config参数详细描述了会话的各项配置信息,保证会话启动后能够按照用户希望的状态运行。它由tensorflow/core/protobuf/config.proto文件中的ConfigProto数据结构定义,用户可使用ConfigProto类的构造方法创建会话的配置参数。表3-13列出了会话的主要配置参数。

    表3-13 会话的主要配置参数

    参数名称功能说明数据类型
    device_count设备数量字典,形如<设备类型, 最大使用数量>map<string, int32>
    intra_op_parallelism_threads独立操作并行计算线程数int32
    inter_op_parallelism_threads阻塞操作并行计算线程数int32
    use_per_session_threads是否使用一套独立线程池取代全局线程池bool
    session_inter_op_thread_pool线程池配置参数ThreadPoolOptionProto
    placement_period重新计算分配节点到设备的周期int32
    device_filters过滤掉不使用的设备string
    gpu_optionsGPU的配置参数GPUOptions
    allow_soft_placement在没有GPU可用时,是否将操作放置到CPU执行bool
    log_device_placement是否打印设备放置日志bool
    graph_options数据流图的配置参数GraphOptions
    operation_timeout_in_ms阻塞操作的最长时限int64
    rpc_options分布式任务运行时的RPC配置参数RPCOptions

    假设用户希望在没有GPU时,TensorFlow能够自动将计算任务转移到CPU上运行,并希望通过日志验证放置情况,那么可以使用如下参数创建会话:

    sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True,
                                            log_device_placement=True))
  2. 运行会话

    运行会话是指基于数据流图和输入数据,求解张量或执行操作的过程。Session类用于运行会话的方法是run,该方法的输入参数见表3-14。由于会话构造时已经绑定了数据流图,在运行时只需要指定待求解的张量和待填充的数据即可。fetches参数用于指定用户待获取的张量,即希望经由会话求解的对象;feed_dict参数用于指定需要填充的数据节点及对应的输入数据。值得一提的是,fetches参数不仅可以接受张量,而且也能接受操作,因为操作的输出本质上也是张量。

    表3-14 Session.run方法的输入参数

    参数名称功能说明
    fetches待求解的张量或操作
    feed_dict数据填充字典,形如<数据节点, 填充数据>
    optionsRunOptions对象,用于设置会话运行时的可选特性开关
    run_metadataRunMetadata对象,用于收集会话运行时的非张量元信息输出

    对于没有数据依赖的张量,调用Session.run方法时无需指定数据填充字典。示例代码如下:

    import tensorflow as tf
    # 创建数据流图:c = a * b
    a = tf.constant(5.0)
    b = tf.constant(6.0)
    c = a * b
    sess = tf.Session()
    # 求解张量c的值;没有依赖数据节点,所以无需填充数据
    print(sess.run(c))
    '''
    输出:
    30.0
    '''

    对于存在数据依赖的张量,调用Session.run方法时,则需要指定数据填充字典。示例代码如下:

    import tensorflow as tf
    # 创建数据流图:z = x * y,其中x和y均为数据节点
    x = tf.placeholder(tf.float32)
    y = tf.placeholder(tf.float32)
    z = x * y
    sess = tf.Session()
    # 求解张量z的值;对数据节点x和y分别填充输入数据3.0和2.0
    print(sess.run(z, feed_dict={x: 3.0, y: 2.0}))
    '''
    输出:
    6.0
    '''

    除了可以使用Session.run方法求解张量,我们还在3.2节中提到过Tensor类的eval方法。事实上,能够实现张量求解的还有Operation类的run方法。图3-11和图3-12分别展示了Tensor.evalOperation.run方法的调用栈。可以看出,它们的内部实现均调用了Session.run方法。

    图3-11 Tensor.eval方法的调用栈

    图3-12 Operation.run方法的调用栈

    相比Session.run方法,Tensor.evalOperation.run方法增加了输入参数session,它用来指定求解张量和执行操作的会话实例。如果用户没有显式设置session参数,TensorFlow不会自动使用之前创建的会话实例。这时如果直接调用Tensor.evalOperation.run方法,程序就会抛出找不到默认会话实例的错误。对于这种场景,用户可以使用with语句创建和运行会话。with语句会隐式调用新建的Session对象的 __enter__ 方法,将当前的会话实例注册为默认会话。因此,在with tf.Session() as sess:语句块中,用户可以使用更简单的参数调用Tensor.evalOperation.run方法。示例代码如下:

    import tensorflow as tf
    # 创建数据流图:y = W * x + b,其中W和b为存储节点,x为数据节点
    x = tf.placeholder(tf.float32)
    W = tf.Variable(1.0)
    b = tf.Variable(1.0)
    y = W * x + b
    with tf.Session() as sess:
      tf.global_variables_initializer().run() # Operation.run
      fetch = y.eval(feed_dict={x: 3.0})      # Tensor.eval
      print(fetch)                            # fetch = 1.0 * 3.0 + 1.0
    '''
    输出:
    4.0
    '''
  3. 关闭会话

    会话提供了TensorFlow数据流图的运行环境,它同时也拥有变量、队列和文件句柄等资源。当执行完计算任务后,应该主动关闭会话,以便释放这些资源。用户一般可以通过以下两种方式关闭会话。

    • 使用close方法显式关闭会话,例如:

      sess.close()
    • 使用with语句隐式关闭会话,例如:

      with tf.Session() as sess:
        sess.run(...)
        # Session.__exit__ 方法被隐式调用,进而调用Session.close方法关闭会话

3.4.2 交互式会话:InteractiveSession

TensorFlow交互式会话为用户提供类似shell的交互式编程环境,它由InteractiveSession类实现。该类的构造方法不仅完成了交互式会话实例的创建,同时将该实例注册为默认会话,这是交互式会话与普通会话的重要区别。因此,如果用户使用交互式会话作为数据流图的运行环境,便可以不借助with上下文语句块,直接调用Tensor.evalOperation.run方法求解张量、执行操作。在这种情况下,用户仍需显式调用InteractiveSession.close方法关闭会话。

使用交互式会话求解张量的示例如下:

import tensorflow as tf
# 创建数据流图:c = a * b
a = tf.constant(5.0)
b = tf.constant(6.0)
c = a * b
# 创建交互式会话
sess = tf.InteractiveSession()
# 求解张量c的值
print(c.eval())
# 关闭交互式会话
sess.close()
'''
输出:
30.0
'''

3.4.3 扩展阅读:会话实现原理

下面简要介绍TensorFlow Python API层面会话类的实现原理,帮助读者加深对会话的理解。

Session类与InteractiveSession类均继承自BaseSession类,BaseSession类实现了接口类SessionInterface定义的会话基本属性。这些类的UML类图如图3-13所示。

图3-13 几种会话类的UML类图

与Java和C++ 不同,Python语言没有内置的接口和抽象类,但可以利用异常机制抛出错误,强制子类必须实现特定的属性和成员方法。以下代码展示了SessionInterface类提供的会话访问接口:

class SessionInterface(object):

  # 会话加载的数据流图
  @property
  def graph(self):
    raise NotImplementedError('graph')

  # 会话连接的执行引擎
  @property
  def sess_str(self):
    raise NotImplementedError('sess_str')

  # 求解张量、执行操作
  def run(self, fetches, feed_dict=None, options=None, run_metadata=None):
    raise NotImplementedError('run')

  # 为partial_run_setup方法设置feeds和fetches参数
  def partial_run_setup(self, fetches, feeds=None):
    raise NotImplementedError('partial_run_setup')

  # 使用partial_run方法设置的feeds和fetches参数,继续执行计算任务
  def partial_run(self, handle, fetches, feed_dict=None):
    raise NotImplementedError('partial_run')

BaseSession类继承了SessionInterface类,并新增了as_defaultclose方法,这两个方法的作用分别是将当前BaseSession实例注册为默认会话,以及关闭当前BaseSession实例。同时,还新增了属性graph_def,它表示序列化后的数据流图,方便用户从文件中加载数据流图继续训练。虽然BaseSession类实现了执行数据流图计算任务的方法,但是用户并不能使用这个类,而应使用其子类SessionInteractiveSession。从面向对象的设计思想看,前者实现了两种会话的公共方法,而后两者则实现会话上下文的不同管理方法。

SessionInteractiveSession类分别实现了resetclose方法。Session类的reset方法主要用于分布式会话的资源释放,包括释放队列和文件句柄资源,以及重置数据流图中的变量。InteractiveSession类重写了父类的close方法,其作用是在关闭交互式会话实例的同时注销加载的数据流图。

3.5 训练工具:优化器

机器学习方法能够在给定的数据中发现潜在的模式,并将发现的模式应用于新数据。机器学习大致分为3种类型,分别是监督学习、无监督学习和半监督学习。其中,监督学习是目前工业界应用最广泛的一类方法。典型的监督学习问题由3部分组成:模型、损失函数和优化算法。在数据中发现的潜在模式就是人们常说的模型。有的模型可以通过解析式精确定义,如线性回归、逻辑回归、高斯混合模型等;有的模型则不能,如多层感知机、深度神经网络等。

简单来说,模型本身是一系列数学表达式和一组参数的组合。监督学习的模型训练是指在给定数据上不断拟合,以求出一组在测试数据集上使得推理值尽可能接近真实值的模型参数。损失函数是指表达模型推理值同真实值差距的函数,用于评估模型的拟合程度;优化算法则是指使用损失值不断优化模型参数,以尽可能减小损失值的算法。目前,主流的监督学习方法主要采用基于梯度下降的优化算法进行模型训练。本节首先介绍损失函数与优化算法等背景知识,然后讲解TensorFlow优化器的定义和使用方法,最后分析TensorFlow优化器的实现原理。

3.5.1 损失函数与优化算法

为了更好地理解模型训练和优化器的工作原理,我们首先介绍监督学习中最重要的两个概念:损失函数和优化算法。

  1. 损失函数

    损失函数是评估特定模型参数和特定输入时,表达模型输出的推理值与真实值之间不一致程度的函数。损失函数L的形式化定义如下:

    {\rm loss}=L(f(x_i;\theta),y_{-i})

    其中,x_i是第i个输入样本,\theta是模型参数,f是用于形式化表示模型的数学函数,y_{-i}x_i对应的真实值。常见的损失函数有平方损失函数、交叉熵损失函数和指数损失函数等,下面分别展示了它们的标准公式:

    \begin{aligned}&{\rm loss}=(y_{-i}-f(x_i;\theta))^2\\&{\rm loss}=y_{-i}*\log(f(x_i;\theta))\\&{\rm loss}=\exp(-y_{-i}*f(x_i;\theta))\end{aligned}

    损失函数是一个非负实值函数,值越小说明模型对训练集拟合得越好。使用损失函数对所有训练样本求损失值,再累加求平均可得到模型的经验风险R_{{\rm emp}}(f)。换句话说,f(x)关于训练集的平均损失就是经验风险,其形式化定义如下:

    R_{{\rm emp}}(f)=\frac{1}{N}\sum^N_{i=1}L(f(x_i;\theta),y_{-i})

    然而,如果过度追求训练数据上的低损失值,就会遇到过拟合问题。训练集通常并不能完全代表真实场景的数据分布。当两者的分布不一致时,如果过分依赖训练集上的数据,面对新数据时就会无所适从,这时模型的泛化能力就会变差。模型训练的目标是不断最小化经验风险。随着训练步数的增加,经验风险将逐渐降低,模型复杂度也将逐渐上升。为了降低过度训练可能造成的过拟合风险,可以引入专门用来度量模型复杂度的正则化项(regularizer)或惩罚项(penalty term)J(f)。常用的正则化项有L0、L1和L2范数。综上,我们将模型最优化的目标替换为健壮性更好的结构风险最小化(structural risk minimization,SRM)。如下所示,它由经验风险项和正则项两部分构成,其中λ代表正则项的权重:

    R_{{\rm srm}}(f)=\min\frac{1}{N}\sum^N_{i=1}L(f(x_i;\theta),y_{-i})+\lambda J(\theta)

    在模型的训练过程中,结构风险不断降低。当R_{{\rm srm}}(f)小于我们设置的损失值阈值时,则认为此时的模型已经满足需求。因此,模型训练的本质就是在最小化结构风险的同时取得最优的模型参数。最优模型参数的形式化定义如下:

    \theta^*=\arg\min\nolimits_{\theta}R_{{\rm srm}}(f)=\arg\min\nolimits_{\theta}\frac{1}{N}\sum\nolimits^{N}_{i=1}L(f(x_i;\theta),y_{-i})+\lambda\Phi(\theta)

  2. 优化算法

    典型的机器学习和深度学习问题通常都需要转换为最优化问题进行求解。最优化是应用数学的一个分支,主要研究以下形式的问题:给定一个函数 f:A\to R,寻找一个属于集合A的元素x_0,使得对于A中的所有xf(x_0)\leqslant f(x)(最小化),或者f(x_0)\geqslant f(x)(最大化)。这里的函数 f 称为目标函数,A的元素称为可行解,使得目标函数值最小化的可行解称为最优解。求解最优化问题的算法称为优化算法,它们通常采用迭代方式实现:首先设定一个初始的可行解,然后基于特定的函数反复重新计算可行解,直到找到一个最优解或达到预设的收敛条件。不同的优化算法采用的迭代策略各有不同,有的使用目标函数的一阶导数,如梯度下降法;有的使用目标函数的二阶导数,如牛顿法;有的使用前几轮迭代的信息,如Adam。基于梯度下降法的迭代策略最简单,它直接沿着梯度负方向,即目标函数减小最快的方向进行直线搜索。这种方法的计算表达式为:

    x_{k+1}=x_k-\alpha^*{\rm grad}(x_k)

    梯度下降法的优点是计算量小,仅计算一阶导数即可;它的缺点是收敛速度慢,只能做到线性收敛。梯度下降法过于简单的策略导致其在优化复杂模型时几乎不会被使用。

    下面我们以一个具体的例子来说明损失函数与优化算法的作用。该问题的构成元素如下。

    • 模型:

      y=f(x)=wx+b

      其中,x是输入数据,y是模型输出的推理值,wb是模型参数。

    • 损失函数:

      {\rm loss}=L(y,y_{-})=L(wx+b,y_{-})

      其中,y_{-}x对应的真实值(标签),loss为损失函数输出的损失值。

    • 优化算法:

      \begin{aligned}w&\leftarrow w+\alpha^*{\rm grad}(w)=\alpha\frac{\partial{\rm loss}}{\partial w}\\b&\leftarrow b+\alpha^*{\rm grad}(b)=\alpha\frac{\partial{\rm loss}}{\partial b}\end{aligned}

      其中,{\rm grad}(w){\rm grad}(b) 分别表示在损失值为loss时wb对应的梯度值,\alpha为学习率。

    模型的训练过程将上述3个部分联系起来。模型f(x) 的具体形式可以千变万化,但本质上都是输入数据为x、输出推理值为y的数学函数。损失函数的作用是定量描述推理值y与真实值y_{-}的不一致程度,即求得损失值loss。利用损失值和模型的拓扑结构,可以计算出模型参数的梯度值gradients。随后,优化算法以一种高效而合理的方式将梯度值更新到对应的模型参数,完成模型的一步迭代训练。

    图3-14是使用TensorBoard可视化上述模型所得的数据流图的结果。我们有意不展示模型推理值y和梯度gradients的内部结构细节,目的是希望此图具有泛化的含义,而非代表某个特定的模型。

    {%}

    图3-14 典型机器学习或深度学习模型的数据流图

3.5.2 优化器概述

优化器是TensorFlow实现优化算法的载体,它为用户实现了自动计算模型参数梯度值的功能。TenosrFlow的优化器根据前向图的计算拓扑和损失值,利用链式求导法则依次求出每个模型参数在给定数据下的梯度值,并将其更新到对应的模型参数以完成一个完整的训练步骤。因此,优化器可以称为TensorFlow的训练工具。

为了说明优化算法的实现原理,我们将图3-14中gradients子图内计算梯度值的结构展开。如图3-15所示,gradients子图是由TensorFlow优化器自动生成的梯度计算子图,即后向图。其中loss和y分别表示对前向图中损失值和推理值求梯度的子图。

{%}

图3-15 计算梯度值的子图内部结构

表3-15罗列了TensorFlow Python API为用户提供的所有优化器,图3-16给出了其中部分典型优化器的类型继承关系。优化器的基类是Optimizer,它定义在tensorflow/python/training/ optimizer.py文件中。与BaseSession类的情况类似,用户并不会直接创建Optimizer类的实例,而是需要创建特定的子类实例。这些优化器均定义在tensorflow/python/training目录下。其中,除了同步优化器(Synchronize Replicas Optimizer)是为分布式训练设计的优化器外,其余都是用于单机模型训练的优化器。这些单机优化器经过同步优化器封装之后,也可以应用于分布式训练场景。

表3-15 TensorFlow提供的优化器

优化器名称

文件路径

Adadelta

tensorflow/python/training/adadelta.py

Adagrad

tensorflow/python/training/adagrad.py

Adagrad Dual Averaging

tensorflow/python/training/adagrad_da.py

Adam

tensorflow/python/training/adam.py

Ftrl

tensorflow/python/training/ftrl.py

Gradient Descent

tensorflow/python/training/gradient_descent.py

Momentum

tensorflow/python/training/momentum.py

Proximal Adagrad

tensorflow/python/training/proximal_adagrad.py

Proximal Gradient Descent

tensorflow/python/training/proximal_gradient_descent.py

Rmsprop

tensorflow/python/training/rmsprop.py

Synchronize Replicas

tensorflow/python/training/sync_replicas_optimizer.py

图3-16 Optimizer类与其子类的继承关系

图3-16只列出了Optimizer类常见的成员变量和成员方法。_name属性为字符串型,表示优化器的名称;_use_locking属性为布尔型,表示是否在并发更新模型参数时加锁。Optimizer类已经实现了minimizecompute_gradientsapply_gradients等常用的顶层方法,其子类一般直接继承这3个方法。但子类必须实现各自的 _apply_dense_apply_sparse方法,它们分别表示使用稠密梯度值和稀疏梯度值更新模型参数的具体实现,两者均返回数据流图上的操作。

3.5.3 使用minimize方法训练模型

模型训练的过程需要最小化损失函数。为了方便用户快速上手,TensorFlow的所有优化器均实现了用于最小化损失函数的minimize方法。该方法在内部会依次调用优化器的compute_gradientsapply_gradients方法。前者计算模型所有参数的梯度值,后者将梯度值更新到对应的模型参数。minimize方法的代码如下所示,这里我们对其中的关键步骤加以注释:

def minimize(self, loss, global_step=None, var_list=None,
             gate_gradients=GATE_OP, aggregation_method=None,
             colocate_gradients_with_ops=False, name=None,
             grad_loss=None):
  # 计算梯度,得到组合后的梯度值与模型参数列表——grads_and_vars,
  # 即<梯度, 参数>键值对列表
  grads_and_vars = self.compute_gradients(
      loss, var_list=var_list, gate_gradients=gate_gradients,
      aggregation_method=aggregation_method,
      colocate_gradients_with_ops=colocate_gradients_with_ops,
      grad_loss=grad_loss)
  # 从grads_and_vars中取出非零梯度值对应的模型参数列表——vars_with_grad
  vars_with_grad = [v for g, v in grads_and_vars if g is not None]
  # 如果没有非零梯度值,则说明模型计算过程中出现了问题
  if not vars_with_grad:
    raise ValueError(
        "No gradients provided for any variable, check your graph for ops"
        " that do not support gradients, between variables %s and loss %s." %
        ([str(v) for _, v in grads_and_vars], loss))
  # 使用非零梯度值更新对应的模型参数
  return self.apply_gradients(grads_and_vars, global_step=global_step,
                              name=name)

表3-16简单介绍了minimize方法的所有输入参数。其中,global_step表示全局训练步数。因为模型训练是一个不断输入数据进行迭代优化的过程,所以我们通常将填充数据、计算梯度、应用梯度更新模型参数这个过程称为一步训练。为了查看训练过程中各评价指标和模型参数的历史变化,需要以训练步数为单位来区分每一步训练结果。虽然global_step也保存在变量中,但是它不需要通过输入数据来进行训练。因此,在创建global_step变量时,需要显式地将trainable参数设置为False,表示不需要在训练过程中自动计算其梯度值。minimize方法内部在应用梯度成功后,会将global_step变量的值加1。通常,我们将minimize方法的返回操作命名为train_optrain_step,成功执行此操作就表示模型完成了一步训练。

表3-16 minimize方法的输入参数

参数名称

功能说明

数据类型

loss

损失值

Tensor

global_step

全局训练步数,随着模型迭代优化自增

Variable

var_list

待训练模型参数的列表

list

gate_gradients

计算梯度和更新参数模型时的并行化程度,可选值为GATE_OP(默认值)、GATE_NONEGATE_GRAPH

Enum

aggregation_method

聚集梯度值的方法,可选值定义在AggregatedMethod类中

Enum

colocate_gradients_with_ops

是否将梯度计算放置到对应操作所在的同一个设备,默认为否

Boolean

name

优化器在数据流图中的名称

String

grad_loss

损失值的梯度

Tensor

在上述参数中,gate_gradients对计算效率有一定影响,值得用户重点关注。用户可以根据自身需求设置gate_gradients的取值,它的三种不同取值的含义如下。

  • GATE_NONE:无同步。最大化并行执行效率,将梯度计算和模型参数更新过程完全并行化。该模式有时会导致部分计算结果无法复现。
  • GATE_OP:操作级同步。对于每个操作,分别确保所有梯度在使用之前都已计算完成。当梯度计算依赖多个输入时,这种做法能够避免计算间的竞争。
  • GATE_GRAPH:图级同步。最小化并行执行效率,在任意梯度被使用之前,确保所有模型参数对应的所有梯度都已经计算完成。

最佳实践

无论模型如何设计,最终都需要使用优化器来进行训练。下面我们以梯度下降优化器为例,介绍使用minimize方法训练模型的典型步骤。代码如下所示:

import tensorflow as tf
# 模型
X = tf.placeholder(...)
Y_ = tf.placeholder(...)
w = tf.Variable(...)
b = tf.Variable(...)
Y = tf.matmul(X, w) + b
# 使用交叉熵作为损失函数
loss = tf.reduce_mean(
    tf.nn.softmax_cross_entropy_with_logits(labels=Y_, logits=Y))
# 优化器
optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.01)
global_step = tf.Variable(0, name='global_step', trainable=False)
train_op = optimizer.minimize(loss, global_step=global_step)
with tf.Session() as sess:
  sess.run(tf.global_variables_initializer())
  for step in xrange(max_train_steps):
    sess.run(train_op, feed_dict={...})
      # 训练日志
      if step % log_steps == 0:
        final_loss, weight, bias = sess.run([loss, w, b],
                                            feed_dict={...})
        print("Step: %d, loss==%.4f, w==%.4f, b==%.4f",
              step, final_loss, weight, bias)
      ...

首先,我们需要创建模型和定义损失函数,并得到对应的损失值(loss)。然后,调用GradientDescentOptimizer类的构造方法,传入学习率(learning_rate)参数,创建梯度下降优化器实例optimizer。接着,创建记录全局训练步数的global_step变量,在构造函数中设置其名称和不可训练的属性。在此基础上。调用optimizer对象的minimize方法,并传入损失值(loss)和global_step变量,以便创建单步训练操作(train_op)。在完成所有变量定义之后,我们需要创建会话,并初始化所有变量值。最后,通过不断执行单步训练操作来训练模型。这里可以设置一个最大训练步数或训练中止条件(如准确率达到90% 或损失值低于0.1等)。同时,也可以定期地打印输出训练日志,以便于观察模型的训练效果。

3.5.4 扩展阅读:模型训练方法进阶

让我们复盘模型训练的最关键部分——优化算法。上一节介绍了使用minimize方法训练模型的最佳实践,该方法具有简单易用的特点,但无法实现对梯度进行特定处理的需求。通过阅读minimize方法的源代码,我们发现它内部先后调用了compute_gradientsapply_gradients方法,而对梯度进行处理的逻辑恰恰可以位于这两者之间。因此,一次更为通用的迭代优化过程可以分为以下三个步骤。

(1) 计算梯度:调用compute_gradients方法,依据指定的策略求得梯度值。

(2) 处理梯度:用户按照自己的需求处理梯度值,如进行梯度裁剪和梯度加权。

(3) 应用梯度:调用apply_gradients方法,将处理后的梯度值应用到模型参数,实现模型更新。

下面分别介绍这三个步骤的实现原理。

  1. 计算梯度

    TensorFlow的所有原生优化器都使用基类Optimizercompute_gradients方法计算梯度,该方法的代码及关键步骤解释如下:

    def compute_gradients(self, loss, var_list=None,
                          gate_gradients=GATE_OP,
                          aggregation_method=None,
                          colocate_gradients_with_ops=False,
                          grad_loss=None):
      if gate_gradients not in [Optimizer.GATE_NONE, Optimizer.GATE_OP,
                                Optimizer.GATE_GRAPH]:
        raise ValueError("gate_gradients must be one of: Optimizer.GATE_NONE, "
                         "Optimizer.GATE_OP, Optimizer.GATE_GRAPH.  Not %s" %
                         gate_gradients)
      self._assert_valid_dtypes([loss])
      if grad_loss is not None:
        self._assert_valid_dtypes([grad_loss])
      if var_list is None:
        var_list = (
            variables.trainable_variables() +
            ops.get_collection(ops.GraphKeys.TRAINABLE_RESOURCE_VARIABLES))
      processors = [_get_processor(v) for v in var_list]
      if not var_list:
        raise ValueError("No variables to optimize.")
      var_refs = [p.target() for p in processors]
      # 创建计算梯度的操作
      grads = gradients.gradients(
          loss, var_refs, grad_ys=grad_loss,
          gate_gradients=(gate_gradients == Optimizer.GATE_OP),
          aggregation_method=aggregation_method,
          colocate_gradients_with_ops=colocate_gradients_with_ops)
      # 如果采用GATE_GRAPH策略,则为所有计算梯度的操作添加控制依赖边,
      # 以保证所有梯度值在使用前都已经计算完毕
      if gate_gradients == Optimizer.GATE_GRAPH:
        grads = control_flow_ops.tuple(grads)
      # 创建梯度和模型参数表
      grads_and_vars = list(zip(grads, var_list))
      self._assert_valid_dtypes([v for g, v in grads_and_vars if g is not None])
      return grads_and_vars

    compute_gradients方法的内部实现中,值得重点关注的是gradients.gradients方法。它的主要输入是损失值和模型参数,输出是计算输入模型参数对应梯度值的操作(grads)。这个操作实现了梯度计算,它能够返回一个包含所有变量对应梯度值的张量列表。gradients.gradients方法对应图3-15中的gradients子图,感兴趣的读者可以在tensorflow/python/ops/gradients_impl.py文件中找到它的定义。该方法的原型如下所示:

    def gradients(ys,
                  xs,
                  grad_ys=None,
                  name="gradients",
                  colocate_gradients_with_ops=False,
                  gate_gradients=False,
                  aggregation_method=None)

    计算出梯度后,compute_gradients方法内部会创建一个梯度和模型参数表,表中便是待更新的模型参数与对应的梯度值。此表会作为方法返回值,提供给用户进行下一步的梯度处理工作。

  2. 处理梯度

    通常,我们在训练模型时总希望快速收敛,但实际情况却往往不那么顺利,梯度有可能在训练过程中变成了无限大或者零值。出现这种情况时,有可能是因为输入数据本身不合法,也有可能是除法或求导运算的精度限制导致的。换句话说,即使模型设计没有问题,仍然存在训练无法顺利进行的情况。为了回避梯度爆炸或梯度消失问题,我们可以对梯度值进行一些处理,再更新到模型参数。例如,可以实施梯度裁剪、梯度加权、模型平均(model averaging)和批规范化(batch normalization)等。

    TensorFlow为用户提供了许多内置的梯度处理方法,以便用户快速实现常见的梯度处理需求。表3-17给出了这些方法的功能说明。同时,TensorFlow也支持自定义梯度处理操作,以此来支持更加个性化的需求。

    表3-17 TensorFlow内置的梯度处理方法

    方法功能说明
    tf.clip_by_value(t, clip_value_min, clip_value_max, name=None)将梯度值t截断到[min, max]区间内,确保梯度的最大值和最小值分别为maxmin
    tf.clip_by_norm(t, clip_norm, axes=None, name=None)使用L2范式规范化梯度t的最大值为clip_norm,返回 t \* clip_norm / l2norm(t)
    tf.clip_by_average_norm(t, clip_norm, name=None)使用平均L2范式规范化梯度t的最大值为clip_norm,返回t \* clip_norm / l2norm_avg(t)
    tf.clip_by_global_norm(t_list,clip_norm, use_norm=None, name=None)根据全局规范化的梯度值列表t_list的加和进行裁剪,返回t_list[i] \* clip_norm / max(global_norm, clip_norm)
    tf.global_norm(t_list, name=None)计算t_list中所有梯度的全局范数,返回global_norm = sqrt(sum([l2norm(t)\*\*2 for t in t_list]))

    下面我们以使用tf.clip_by_norm方法对Adam优化器计算出的梯度值进行裁剪为例,介绍TensorFlow中梯度处理的典型步骤。代码如下所示:

    import tensorflow as tf
    ... # 创建模型
    optimizer = tf.train.AdamOptimizer(learning_rate, beta1=0.5)
    grads_and_vars = optimizer.compute_gradients(loss)
    for i, (g, v) in enumerate(grads_and_vars):
      if g is not None:
        grads_and_vars[i] = (tf.clip_by_norm(g, 5), v)  # 裁剪梯度
    train_op = optimizer.apply_gradients(grads_and_vars)
    ... # 创建会话,训练模型

    在本例中,创建模型、定义Adam优化器及计算梯度的方法参考了上一节的最佳实践。取得梯度值和模型参数列表grads_and_vars之后,可以使用tf.clip_by_norm方法对列表中所有非零梯度值进行裁剪和更新,以实现L2范式规范化。在参数更新之后,我们调用Adam优化器的apply_gradients方法,传入新的梯度值和模型参数列表。后续会话创建、模型训练的步骤同前例,并无差异。

  3. 应用梯度

    作为minimize方法内部实现的后半部分,apply_gradients方法的主要输入是处理后的梯度值与对应模型参数组合而成的列表(grads_and_vars),输出是用于更新所有模型参数的操作(apply_udpates)。下面给出了基类Optimizerapply_gradients方法的定义,我们结合代码分析它的工作原理:

    def apply_gradients(self, grads_and_vars, global_step=None, name=None):
      grads_and_vars = tuple(grads_and_vars)
      if not grads_and_vars:
        raise ValueError("No variables provided.")
      converted_grads_and_vars = []
      for g, v in grads_and_vars:
        if g is not None:
          try:
            g = ops.convert_to_tensor_or_indexed_slices(g)
          except TypeError:
            raise TypeError(
                "Gradient must be convertible to a Tensor"
                " or IndexedSlices, or None: %s" % g)
          if not isinstance(g, (ops.Tensor, ops.IndexedSlices)):
            raise TypeError(
                "Gradient must be a Tensor, IndexedSlices, or None: %s" % g)
        p = _get_processor(v)
        converted_grads_and_vars.append((g, v, p))
      converted_grads_and_vars = tuple(converted_grads_and_vars)
      var_list = [v for g, v, _ in converted_grads_and_vars if g is not None]
      if not var_list:
        raise ValueError("No gradients provided for any variable: %s." %
                         ([str(v) for _, _, v in converted_grads_and_vars],))
      with ops.control_dependencies(None):
        self._create_slots(var_list)
    
      update_ops = []
      with ops.name_scope(name, self._name) as name:
        self._prepare()
        for grad, var, processor in converted_grads_and_vars:
          if grad is None:
            continue
          with ops.name_scope("update_" + var.op.name), ops.colocate_with(var):
            update_ops.append(processor.update_op(self, grad))
    
        if global_step is None:
          apply_updates = self._finish(update_ops, name)
        else:
          with ops.control_dependencies([self._finish(update_ops, "update")]):
            with ops.colocate_with(global_step):
              apply_updates = state_ops.assign_add(global_step, 1, name=name).op
    
        train_op = ops.get_collection_ref(ops.GraphKeys.TRAIN_OP)
        if apply_updates not in train_op:
          train_op.append(apply_updates)
    
        return apply_updates

    apply_gradients方法接受经过处理的梯度和模型参数列表grads_and_vars作为输入。处理后的梯度值可能不是张量类型了,但是程序仍然需要使用张量类型来表示梯度,以此来继续构建数据流图。为此,apply_gradients方法内部首先需要将梯度值转换为张量类型。如果能够成功转换,那么将其与对应的模型参数保存到converted_grads_and_vars列表。随后,将converted_grads_and_vars列表转换为元组类型,使其能够支持列表推导式运算。

    接着,apply_gradients方法逐对遍历converted_grads_and_vars中的梯度值和模型参数,根据产生模型参数的操作名称找到对应参数的更新操作,并将其追加到更新操作汇总表update_ops中。在汇总所有的更新操作后,使用Optimizer_finish方法增加显式的控制依赖。根据数据流图中的拓扑结构,程序会先执行apply_updates依赖的所有输入节点,即更新操作汇总表update_ops中的所有更新操作。在并行更新模型参数的过程中,Optimizer._finish方法能够确保更新操作按照拓扑序正确执行。它的定义如下:

    def _finish(self, update_ops, name_scope):
      return control_flow_ops.group(*update_ops, name=name_scope)

    换句话说,在apply_updates操作执行之前,update_ops中的所有操作都已经执行完成,以确保所有模型参数都已更新成功。如果global_step变量不为空,执行完update_ops中的所有操作后,global_step变量值会被加1,以此作为训练步数的记录。最后,apply_updates成为单步训练的计算节点。即当用户成功执行一次apply_updates操作时,模型就完成了一步训练。

    其中,模型参数更新操作在内部调用了优化器的 _apply_dense_apply_sparse方法,这两个方法分别实现了使用稠密梯度值和稀疏梯度值更新对应模型参数的逻辑。每种优化器的更新策略千差万别,基类Optimizer无法实现通用的更新策略。因此,它只是简单地定义了这两个成员方法,并为其添加了NotImplementedError异常,以确保子类在继承时必须实现相应的方法。如果用户想要自定义优化器,则需要分别实现这两个成员方法。

    综上,模型训练的关键是对优化器的学习和掌握。本节中,我们从理论指导和实践落地这两个角度分别介绍了TensorFlow的训练工具——优化器。在使用优化器训练模型时,我们可以选择简单易用的minimize方法,也可以调用compute_gradientsapply_gradients方法,并插入梯度处理逻辑来增加灵活性。

3.6 一元线性回归模型的最佳实践

本节以最佳实践的形式介绍如何利用张量、操作、优化器和会话创建并训练简单的一元线性回归模型。读者通过详细阅读各核心步骤的典型实现,结合前面5节的知识,能够更加深入地理解TensorFlow的基本概念和使用方法。

在统计学中,线性回归(linear regression)是一种回归分析方法,它利用基于最小二乘函数的回归方程对一个或多个自变量和因变量之间的关系进行建模。只有一个自变量的情况称为简单回归,大于一个自变量的情况叫作多元回归或N元回归。一般情况下,具有N个自变量的N元线性回归模型的形式化定义如下:

Y=\boldsymbol{W}^{{\rm T}}X+b=w_1x_1+w_2x_2+\cdots+w_Nx_N+b

其中,\boldsymbol{W}是权重矩阵,b是偏置量,X是自变量,Y是因变量。N元线性回归模型描述了N个自变量与1个因变量之间的线性关系。当N=1时,即:

Y=\boldsymbol{W}X+b=w_1x_1+b

图 3-17 展示了一元线性回归模型的数据流图表达形式。其中,前向图的结构是权重矩阵\boldsymbol{W}与因变量X相乘,再加上偏置b,得到推理值Y;后向图则基于梯度下降优化算法,使用优化器计算出的梯度值直接更新权重矩阵\boldsymbol{W}和偏置b

{%}

图3-17 一元线性回归模型的数据流图表达形式

使用TensorFlow训练该模型的典型过程可以分为以下8个步骤:

(1) 定义超参数;

(2) 输入数据;

(3) 构建模型;

(4) 定义损失函数;

(5) 创建优化器;

(6) 定义单步训练操作;

(7) 创建会话;

(8) 迭代训练。

下面分别介绍每个步骤,然后给出可视化模型训练过程的方法供读者参考。

  1. 定义超参数

    超参数是指模型训练过程中使用的配置参数。超参数的取值往往影响模型的收敛速度或预测精度。常见的超参数有学习率、隐藏层神经元个数、批数据个数和正则项系数等。通常情况下,我们会参考经典模型训练的经验数值设置超参数,比如将梯度下降优化器的学习率设为0.01:

    # 学习率
    learning_rate = 0.01
    # 最大训练步数
    max_train_steps = 1000
  2. 输入数据

    在有监督的机器学习中,数据集一般划分为训练集(training set)、验证集(validation set)和测试集(test set)。通常,训练集用于训练模型或确定模型参数;验证集用于控制模型复杂度或确定模型结构;测试集用于测试模型的性能和泛化能力。本例构造了17对训练数据:

    # 构造训练数据
    train_X = np.array([[3.3],[4.4],[5.5],[6.71],[6.93],[4.168],[9.779],[6.182],[7.59],[2.167],
        [7.042],[10.791],[5.313],[7.997],[5.654],[9.27],[3.1]], dtype=np.float32)
    train_Y = np.array([[1.7],[2.76],[2.09],[3.19],[1.694],[1.573],[3.366],[2.596],[2.53],
        [1.221],[2.827],[3.465],[1.65],[2.904],[2.42],[2.94],[1.3]], dtype=np.float32)
    total_samples = train_X.shape[0]

    因为一元线性回归模型的结构非常简单,所以不再单独构造验证集和测试集。

  3. 构建模型

    构建模型是指编写数据流图对应的代码,描述算法模型结构的过程。对于训练数据的属性,可以使用占位符描述。本例中的X是单精度浮点数,形状定义为 [None, 1],其中None表示支持任意个数的输入,1表示数据是一维的。对于变量和计算操作,则使用相应的节点构造方法或全局函数定义。模型参数W是形状为 [1, 1] 的单精度浮点数矩阵,偏置量b是单精度浮点数标量,它们分别使用正态分布和零值进行初始化。将XW都定义为矩阵,是为了方便将所有的训练数据一次性填充以进行推理计算。推理值Y的定义源于一元线性回归模型的形式化定义。需要注意的是,这行语句属于声明式的、用于构图的符号表达式,而非命令式的、立即执行的代码。相关代码如下:

    # 输入数据
    X = tf.placeholder(tf.float32, [None, 1])
    # 模型参数
    W = tf.Variable(tf.random_normal([1, 1]), name="weight")
    b = tf.Variable(tf.zeros([1]), name="bias")
    # 推理值
    Y = tf.matmul(X, W) + b
  4. 定义损失函数

    接着,我们使用Y_{-} 描述训练数据X对应的实际值。这里采用均方差作为模型训练的损失函数。均方差具有形式简单、计算量小等特点,它的形式化定义如下:

    {\rm loss}={\rm MSE}=\frac{1}{n}\sum^n_{i=1}(Y_i-Y_{-i})^2

    相关代码如下:

    # 实际值
    Y_ = tf.placeholder(tf.float32, [None, 1])
    # 均方差
    loss = tf.reduce_sum(tf.pow(Y-Y_, 2))/(total_samples)
  5. 创建优化器

    一元线性回归模型比较简单,本例直接使用随机梯度下降优化器计算梯度值:

    # 随机梯度下降
    optimizer = tf.train.GradientDescentOptimizer(learning_rate)
  6. 定义单步训练操作

    这里将单步训练操作(train_op)设置为使用随机梯度下降优化器的minimize方法计算和应用梯度。minimize方法在内部实现了梯度计算和模型更新的过程。每当用户在会话中成功执行一次单步训练操作,就使得前向图和后向图依次执行了一遍。相关代码如下:

    # 最小化损失值
    train_op = optimizer.minimize(loss)
  7. 创建会话

    定义好算法模型相关的对象之后,需要定义TensorFlow运行时框架相关的对象。对于本例这种简单的程序,只需要定义用于维护训练上下文的会话对象。这里使用Session类的构造方法创建会话,然后调用global_variables_initializer方法初始化全局变量:

    with tf.Session() as sess:
      # 初始化全局变量
      sess.run(tf.global_variables_initializer())
  8. 迭代训练

    前面我们已经设定模型总共迭代训练1000步。每一步训练都需要填充全部17对训练数据。因为模型参数Wb会不断更新,所以同样的训练数据集上每一步计算出的损失值都不同。在示例代码中,我们让程序定期输出日志,以便观察参数和损失值的变化。训练结束之后,计算并输出最终的模型参数。相关代码如下:

    print("Start training:")
    for step in xrange(max_train_steps):
      sess.run(train_op, feed_dict={X: train_X, Y_: train_Y})
      # 每隔log_step步打印一次日志
      if step % log_step == 0:
        c = sess.run(loss, feed_dict={X: train_X, Y_:train_Y})
        print("Step:%d, loss==%.4f, W==%.4f, b==%.4f" %
             (step, c, sess.run(W), sess.run(b)))
    # 计算训练完毕的模型在训练集上的损失值,并将其作为指标输出
    final_loss = sess.run(loss, feed_dict={X: train_X, Y_: train_Y})
    # 计算训练完毕的模型参数W和b
    weight, bias = sess.run([W, b])
    print("Step:%d, loss==%.4f, W==%.4f, b==%.4f" %
         (max_train_steps, final_loss, sess.run(W), sess.run(b)))
    print("Linear Regression Model: Y==%.4f*X+%.4f" % (weight, bias))
  9. 模型可视化

    在Jupyter Notebook等Python交互式环境下,我们可以使用matplotlib库实现模型的可视化。具体步骤为:首先,执行 %matplotlib命令,初始化matplotlib后端。然后,调用plt.plot方法,设置横纵坐标的指标和属性。最后,调用plt.show方法绘制图形。相关代码如下:

    # 初始化matplotlib后端
    %matplotlib
    # 根据训练数据X和Y,添加对应的红色圆点
    plt.plot(train_X, train_Y, 'ro', label='Training data')
    # 根据模型参数和训练数据,添加蓝色(默认色)拟合直线
    plt.plot(train_X, weight * train_X + bias, label='Fitted line')
    # 添加图例说明
    plt.legend()
    # 绘制图形
    plt.show()

    输出结果如图3-18所示。

    {%}

    图3-18 使用matplotlib渲染的一元线性回归模型

3.7 小结

TensorFlow的核心编程范式是基于声明式编程的数据流图。数据流图由节点和有向边组成,能够有效表达机器学习和深度学习领域的各类算法模型,具有代码可读性强、支持引用透明、提供预编译优化能力等优点。从实现的角度来看,一个典型的TensorFlow应用程序包含数据载体、模型载体、运行环境和训练工具这4个部分。张量是TensorFlow的数据载体,它可以描述数据流图上流动的任意类型和形状的数据,具有极高的灵活性。操作是TensorFlow的模型载体,计算操作、变量操作和占位符操作分别描述了模型的计算拓扑、模型参数和数据集。会话是TensorFlow的运行环境,它连接后端执行引擎,为数据流图的执行维护计算资源和存储资源。优化器是TensorFlow的训练工具,它为用户提供了多种梯度计算和模型更新的方法。掌握这些基础概念、习惯声明式编程思维,是正确地使用TensorFlow开发应用的前提。

目录