TensorFlow技术解析与实战

1 TensorFlow基础知识

1.1 系统架构

tensorflow项目架构 tensorflow架构及原理_tensorflow

图1

图中给出的是TensorFlow的系统架构,自底向上分为设备层和网络层、数据操作层、图计
算层、API层、应用层
,其中设备层和网络层、数据操作层、图计算层是TensorFlow的核心层。

下面就自底向上详细介绍一下TensorFlow的系统架构。

  • 最下层是网络通信层和设备管理层
  • 网络通信层包括gRPC(google Remote Procedure Call Protocol)和远程直接数据存取(Remote Direct Memory Access,RDMA),这都是在分布式计算时需要用到的。
  • 设备管理层包括TensorFlow分别在CPU、GPU、FPGA等设备上的实现,也就是对上层提供了一个统一的接口,使上层只需要处理卷积等逻辑,而不需要关心在硬件上的卷积的实现过程。
  • 其上是数据操作层,主要包括卷积函数、激活函数等操作(参见4.7节)。再往上是图计算层,也是我们要了解的核心,包含本地计算图和分布式计算图的实现
  • 再往上是API层和应用层。

1.2 设计理念

TensorFlow的设计理念主要体现在以下两个方面。

  • 将图的定义和图的运行完全分开。

因此,TensorFlow被认为是一个“符号主义”的库。

我们知道,编程模式通常分为命令式编程 (imperative style programming)和符号式编程(symbolic style programming)

命令式编程就是编写我们理解的通常意义上的程序,很容易理解和调试,按照原有逻辑执行。

符号式编程涉及很多的嵌入和优化,不容易理解和调试,但运行速度相对有所提升。现有的深度学习框架中,Torch是典型的命令式的,Caffe、MXNet采用了两种编程模式混合的方法,而TensorFlow完全采用符号式编程。

符号式计算一般是先定义各种变量,然后建立一个数据流图,在数据流图中规定各个变
量之间的计算关系,最后需要对数据流图进行编译,但此时的数据流图还是一个空壳儿,里面没有任何实际数据,只有把需要运算的输入放进去后,才能在整个模型中形成数据流,从而形成输出值。

例如

t = 8 + 9
print(t)

在传统的程序操作中,定义了t的运算,在运行时就执行了,并输出17。而在TensorFlow
中,数据流图中的节点,实际上对应的是TensorFlow API中的一个操作,并没有真正去运行:

import tensorflow as tf
t = tf.add(8, 9)
print(t)
# Tensor("Add_1:0", shape=(), dtype=int32)

定义了一个操作,但实际上并没有运行。

  • TensorFlow中涉及的运算都要放在图中,而图的运行只发生在会话 (session)中

开启会话后,就可以用数据去填充节点,进行运算;关闭会话后,就不能进行计算了。因此,会话提供了操作运行和Tensor求值的环境。例如:

# 创建图
a = tf.constant([1.0, 2.0])
b = tf.constant([3.0, 4.0])
c = a*b

# 创建会话
sess = tf.Session()

# 计算C
print(sess.run(c)) # 进行矩阵乘法,输出[3., 8.]
sess.close()

1.3 编程模型

TensorFlow是用数据流图做计算的,因此我们先创建一个数据流图(也称为网络结构图),
如图2所示,看一下数据流图中的各个要素。

tensorflow项目架构 tensorflow架构及原理_API_02

图2 图2讲述了TensorFlow的运行原理。图中包含输入(input)、塑形(reshape)、Relu层(Relu layer)、Logit层(Logit layer)、Softmax、交叉熵(cross entropy)、梯度(gradient)、SGD训练(SGD Trainer)等部分,是一个简单的回归模型。

它的计算过程是,首先从输入开始,经过塑形后,一层一层进行前向传播运算。Relu层(隐
藏层)里会有两个参数,即 \(W_{h1}\) 和 \(b_{h1}\) ,在输出前使用ReLu(Rectified Linear Units)激活函数做非线性处理。然后进入Logit层(输出层),学习两个参数 \(W_{sm}\) 和 \(b_{sm}\) 。用Softmax来计算输出结果中各个类别的概率分布。用交叉熵来度量两个概率分布(源样本的概率分布和输出结果的概率分布)之间的相似性。然后开始计算梯度,这里是需要参数 \(W_{h1}\) 、 \(b_{h1}\) 、\(W_{sm}\) 、 \(b_{sm}\) ,以及交叉熵后的结果。随后进入SGD训练,也就是反向传播的过程,从上往下计算每一层的参数,依次进行更新。也就是说,计算和更新的顺序为 \(b_{sm}、W_{sm} 、b_{h1}\) 和 \(W_{h1}\)

  • TensorFlow是指“张量的流动”。
  • TensorFlow的数据流图是由节点 (node)和边(edge)组成的有向无环图 (directed acycline graph,DAG)。
  • TensorFlow由Tensor和Flow两部分组成,Tensor(张量)代表了数据流图中的边,而Flow(流动)这个动作就代表了数据流图中节点所做的操作。

1.3.1 边

TensorFlow的边有两种连接关系:数据依赖和控制依赖 。

其中,实线边表示数据依赖,代表数据,即张量。任意维度的数据统称为张量。在机器学习算法中,张量在数据流图中从前往后流动一遍就完成了一次前向传播 (forword propagation),而残差从后向前流动一遍就完成了一次反向传播 (backword propagation)。

还有一种特殊边,一般画为虚线边,称为控制依赖 (control dependency),可以用于控制操作的运行,这被用来确保happens-before关系,这类边上没有数据流过,但源节点必须在目的节点开始执行前完成执行。

常用代码如下:

tf.Graph.control_dependencies(control_inputs)

TensorFlow支持的张量具有表1所示的数据属性。

表1 | 数据类型 | Python类型 | 描述 | | ------------ | ------------ | -------------------------------------------------- | | DT_FLOAT | tf.float32 | 32位浮点型 | | DT_DOUBLE | tf.float64 | 64位浮点型 | | DT_INT64 | tf.int64 | 64位有符号整型 | | DT_INT32 | tf.int32 | 32位有符号整型 | | DT_INT16 | tf.int16 | 16位有符号整型 | | DT_INT8 | tf.int8 | 8位有符号整型 | | DT_UINT8 | tf.uint8 | 8位无符号整型 | | DT_STRING | tf.string | 可变长度的字节数组,每一个张量元素都是一个字节数组 | | DT_BOOL | tf.bool | 布尔型 | | DT_COMPLEX64 | tf.complex64 | 由两个32位浮点数组成的复数:实部和虚部 | | DT_QINT32 | tf.qint32 | 用于量化 [9] 操作的32位有符号整型 | | DT_QINT8 | tf.qint8 | 用于量化操作的8位有符号整型 | | DT_QUINT8 | tf.quint8 | 用于量化操作的8位无符号整型 |

有关图及张量的实现的源代码均位于tensorflow-1.1.0/tensorflow/python/framework/ops.py

1.3.2 节点

图中的节点 又称为算子 ,它代表一个操作 (operation,OP),一般用来表示施加的数学运算,也可以表示数据输入 (feed in)的起点以及输出 (push out)的终点,或者是读取/写入持久变量 (persistent variable)的终点。表2列举了一些TensorFlow实现的算子。算子支持表1所示的张量的各种数据属性,并且需要在建立图的时候确定下来。

表2 | 类别 | 示例 | | ------------------ | ------------------------------------------------------------ | | 数学运算操作 | Add、Subtract、Multiply、Div、Exp、Log、Greater、Less、Equal…… | | 数组运算操作 | Concat、Slice、Split、Constant、Rank、Shape、Shuffle…… | | 矩阵运算操作 | MatMul、MatrixInverse、MatrixDeterminant…… | | 有状态的操作 | Variable、Assign、AssignAdd…… | | 神经网络构建操作 | SoftMax、Sigmoid、ReLU、Convolution2D、MaxPool,…… | | 检查点操作 | Save、Restore | | 队列和同步操作 | Enqueue、Dequeue、MutexAcquire、MutexRelease…… | | 控制张量流动的操作 | Merge、Switch、Enter、Leave、NextIteration |

与操作相关的代码位于tensorflow-1.1.0/tensorflow/python/ops/目录下。以本人环境为例

tensorflow项目架构 tensorflow架构及原理_tensorflow_03

以数学运算为例,代码为上述目录下的math_ops.py,里面定义了add、subtract、multiply、scalar_mul、div、divide、truediv、floordiv等数学运算,每个函数里面调用了gen_math_ops.py中的方法,这个文件是在编译(安装时)TensorFlow时生成的,位于Python库sitepackages/tensorflow/python/ops/gen_math_ops.py中,随后又调用了tensorflow-1.1.0/tensorflow/core/kernels/下面的核函数实现。再例如,数据运算的代码位于tensorflow-1.1.0/tensorflow/python/ops/array_ops.py中,里面定义了concat、split、slice、size、rank等运算,每个函数都调用了gen_array_ops.py中的方法,这个文件也是在编译TensorFlow时生成的,位于Python库site-packages/tensorflow/python/ops/gen_array_ops.py中,随后又调用了tensorflow-1.1.0/tensorflow/core/kernels/下面的核函数实现。

1.3.3 图

把操作任务描述成有向无环图。

那么,如何构建一个图呢?

构建图的第一步是创建各个节点。

具体如下:

import tensorflow as tf

# 创建一个常量运算操作,产生一个 1×2 矩阵
matrix1 = tf.constant([[3., 3.]])
# 创建另外一个常量运算操作,产生一个 2×1 矩阵
matrix2 = tf.constant([[2.],[2.]])

# 创建一个矩阵乘法运算 ,把matrix1和matrix2作为输入
# 返回值product代表矩阵乘法的结果
product = tf.matmul(matrix1, matrix2)

1.3.4 会话

启动图的第一步是创建一个Session对象。会话(session)提供在图中执行操作的一些方法。一般的模式是,建立会话,此时会生成一张空图,在会话中添加节点和边,形成一张图,然后执行。

要创建一张图并运行操作的类,在Python的API中使用tf.Session,在C++ 的API中使用tensorflow::Session。

示例如下:

with tf.Session() as sess:
    result = sess.run([product])
    print(result)

在调用Session对象的run()方法来执行图时,传入一些Tensor,这个过程叫填充 (feed);返回的结果类型根据输入的类型而定,这个过程叫取回 (fetch)

与会话相关的源代码位于tensorflow-1.1.0/tensorflow/python/client/session.py。

会话是图交互的一个桥梁,一个会话可以有多个图,会话可以修改图的结构,也可以往图中注入数据进行计算。

因此,会话主要有两个API接口:Extend和Run。

  • Extend操作是在Graph中添加节点和边;
  • Run操作是输入计算的节点和填充必要的数据后,进行运算,并输出运算结果。

1.3.5 设备

设备 (device)是指一块可以用来运算并且拥有自己的地址空间的硬件,如GPU和CPU。TensorFlow为了实现分布式执行操作,充分利用计算资源,可以明确指定操作在哪个设备上执行。

具体如下:

with tf.Session() as sess:
    # 指定在第二个gpu上运行
    with tf.device("/gpu:1"):
        matrix1 = tf.constant([[3., 3.]])
        matrix2 = tf.constant([[2.],[2.]])
        product = tf.matmul(matrix1, matrix2)

与设备相关的源代码位于tensorflow-1.1.0/tensorflow/python/framework/device.py。

1.3.6 变量

变量 (variable)是一种特殊的数据,它在图中有固定的位置,不像普通张量那样可以流动。

例如,创建一个变量张量,使用tf.Variable()构造函数,这个构造函数需要一个初始值,初始值的形状和类型决定了这个变量的形状和类型:

# 创建一个变量,初始化为标量0
state = tf.Variable(0, name="counter")

创建一个常量张量

input1 = tf.constant(3.0)

TensorFlow 还提供了填充机制,可以在构建图时使用tf.placeholder()临时替代任意操作的张量,在调用Session对象的run()方法去执行图时,使用填充数据作为调用的参数,调用结束后,填充数据就消失。

代码示例如下:

input1 = tf.placeholder(tf.float32)
input2 = tf.placeholder(tf.float32)
output = tf.multiply(input1, input2)
with tf.Session() as sess:
    print(sess.run([output], feed_dict={input1:[7.], input2:[2.]}))

# out:[array([14.], dtype=float32)]

与变量相关的源代码位于tensorflow/tensorflow/python/ops/variables.py。

1.3.7 内核

我们知道操作 (operation)是对抽象操作(如matmul或者add)的一个统称,而内核 (kernel)则是能够运行在特定设备(如CPU、GPU)上的一种对操作的实现。因此,同一个操作可能会对应多个内核。

当自定义一个操作时,需要把新操作和内核通过注册的方式添加到系统中。后面1.10会用一个示例来讲解如何自定义一个操作。

1.4 常用API

了解TensorFlow的API有助于在应用时得心应手。下面介绍的是常用API,在后面的示例中基本上都会用到。这里主要介绍基于Python的API,基于其他语言的API也大同小异,最重要的是理解API的功能及其背后的原理。

1.4.1 图、操作和张量

TensorFlow的计算表现为数据流图,所以tf.Graph类中包含一系列表示计算的操作对象(tf.Operation),以及在操作之间流动的数据——张量对象(tf.Tensor)。与图相关的API均位于tf.Graph类中,参见表3。

表3 | 操作 | 描述 | | ---------------------------------------- | ------------------------------------------------------------ | | tf.Graph.init () | 创建一个空图 | | tf.Graph.as_default() | 将某图设置为默认图,并返回一个上下文管理器。如果不显式添加一 个默认图,系统会自动设置一个全局的默认图。所设置的默认图,在 模块范围内定义的节点都将默认加入默认图中 | | tf.Graph.device(device_name_or_function) | 定义运行图所使用的设备,并返回一个上下文管理器 | | tf.Graph.name_scope(name) | 为节点创建层次化的名称,并返回一个上下文管理器 |

tf.Operation类代表图中的一个节点,用于计算张量数据。该类型由节点构造器(如tf.matmul()或者Graph.create_op())产生。例如,c = tf.matmul(a, b)创建一个Operation类,其类型
为MatMul的操作类。与操作相关的API均位于tf.Operation类中,参见表4。

表4 | 操作 | 描述 | | ---------------------------------------------- | -------------------- | | tf.Operation.name | 操作的名称 | | tf.Operation.type | 操作的类型,如MatMul | | tf.Operation.inputstf.Operation.outputs | 操作的输入与输出 | | tf.Operation.control_inputs | 操作的依赖 | | tf.Operation.run(feed_dict=None, session=None) | 在会话中运行该操作 | | tf.Operation.get_attr(name) | 获取操作的属性值 |

tf.Tensor类是操作输出的符号句柄,它不包含操作输出的值,而是提供了一种在tf.Session中计算这些值的方法。这样就可以在操作之间构建一个数据流连接,使TensorFlow能够执行一个表示大量多步计算的图形。与张量相关的API均位于tf.Tensor类中,参见表5。

表5 | 操作 | 描述 | | ------------------------------------------------- | ------------------------------------------------------------ | | tf.Tensor.dtype | 张量的数据类型 | | tf.Tensor.name | 张量的名称 | | tf.Tensor.value_index | 张量在操作输出中的索引 | | tf.Tensor.graph | 张量所在的图 | | tf.Tensor.op | 产生该张量的操作 | | tf.Tensor.consumers() | 返回使用该张量的操作列表 | | tf.Tensor.eval(feed_dict=None, session=None) | 在会话中求张量的值,需要使用sess.as_default()或者 eval(session=sess) | | tf.Tensor.get_shape() | 返回用于表示张量的形状(维度)的类TensorShape | | tf.Tensor.set_shape(shape) | 更新张量的形状 | | tf.Tensor.device | 设置计算该张量的设备 |

1.4.2 可视化

如何编写可视化的程序呢?

可视化时,需要在程序中给必要的节点添加摘要 (summary),摘要会收集该节点的数据,并标记上第几步、时间戳等标识,写入事件文件 (event file)中。tf.summary.FileWriter类用于在目录中创建事件文
件,并且向文件中添加摘要和事件,用来在TensorBoard中展示。

表6给出了可视化常用的API操作。

表6 | API | 描述 | | ------------------------------------------------------------ | ------------------------------------------------------------ | | tf.summary.FileWriter.init (logdir, graph=None, max_queue= 10, flush_secs=120, graph_def=None) | 创建FileWriter和事件文件,会在logdir中创建 一个新的事件文件 | | tf.summary.FileWriter.add_summary(summary, global_step=None) | 将摘要添加到事件文件 | | tf.summary.FileWriter.add_event(event) | 向事件文件中添加一个事件 | | tf.summary.FileWriter.add_graph(graph, global_step=None, graph_def=None) | 向事件文件中添加一个图 | | tf.summary.FileWriter.get_logdir() | 获取事件文件的路径 | | tf.summary.FileWriter.flush() | 将所有事件都写入磁盘 | | tf.summary.FileWriter.close() | 将事件写入磁盘,并关闭文件操作符 | | tf.summary.scalar(name, tensor, collections=None) | 输出包含单个标量值的摘要 | | tf.summary.histogram(name, values, collections=None) | 输出包含直方图的摘要 | | tf.summary.audio(name, tensor, sample_rate, max_outputs=3, collections=None) | 输出包含音频的摘要 | | tf.summary.image(name, tensor, max_outputs=3, collections= None) | 输出包含图片的摘要 | | tf.summary.merge(inputs, collections=None, name=None) | 合并摘要,包含所有输入摘要的值 |

1.5 变量作用域

在TensorFlow中有两个作用域 (scope),一个是name_scope,另一个是variable_scope。它们究竟有什么区别呢?

  • name_scope主要是给variable_name加前缀,也可以给op_name加前缀;
  • name_scope是给op_name加前缀。

下面我们就来分别介绍。

1.5.1 variable_scope示例

variable_scope变量作用域机制在TensorFlow中主要由两部分组成:

v = tf.get_variable(name, shape, dtype, initializer) # 通过所给的名字创建或是返回一个变量
tf.variable_scope(<scope_name>) # 为变量指定命名空间

当tf.get_variable_scope().reuse == False时,variable_scope作用域只能用来创建新变量:

with tf.variable_scope("foo"):
    v = tf.get_variable("v", [1])
    v2 = tf.get_variable("v", [1])
assert v.name == "foo/v:0"

上述程序会抛出ValueError错误,因为v这个变量已经被定义过了,但tf.get_variable_scope().reuse默认为False,所以不能重用。

当tf.get_variable_scope().reuse == True时,作用域可以共享变量:

with tf.variable_scope("foo") as scope:
    v = tf.get_variable("v", [1])
with tf.variable_scope("foo", reuse=True):
    #也可以写成:
    #scope.reuse_variables()
    v1 = tf.get_variable("v", [1])
assert v1 == v

1.获取变量作用域

可以直接通过tf.variable_scope()来获取变量作用域:

with tf.variable_scope("foo") as foo_scope:
    v = tf.get_variable("v", [1])
with tf.variable_scope(foo_scope):
    w = tf.get_variable("w", [1])

如果在开启的一个变量作用域里使用之前预先定义的一个作用域,则会跳过当前变量的
作用域,保持预先存在的作用域不变。

with tf.variable_scope("foo") as foo_scope:
    assert foo_scope.name == "foo"
with tf.variable_scope("bar"):
    with tf.variable_scope("baz") as other_scope:
        assert other_scope.name == "bar/baz"
        with tf.variable_scope(foo_scope) as foo_scope2:
            assert foo_scope2.name == "foo" # 保持不变

2.变量作用域的初始化

变量作用域可以默认携带一个初始化器,在这个作用域中的子作用域或变量都可以继承
或者重写父作用域初始化器中的值。方法如下:

with tf.variable_scope("foo", initializer=tf.constant_initializer(0.4)):
	v = tf.get_variable("v", [1])
	assert v.eval() == 0.4 # 被作用域初始化
	w = tf.get_variable("w", [1], initializer=tf.constant_initializer(0.3)):
	assert w.eval() == 0.3 # 重写初始化器的值
	with tf.variable_scope("bar"):
		v = tf.get_variable("v", [1])
		assert v.eval() == 0.4 # 继承默认的初始化器
	with tf.variable_scope("baz", initializer=tf.constant_initializer(0.2)):
		v = tf.get_variable("v", [1])
		assert v.eval() == 0.2 # 重写父作用域的初始化器的值

上面讲的是variable_name,那对于op_name呢?在variable_scope作用域下的操作,也会被加上前缀:

with tf.variable_scope("foo"):
	x = 1.0 + tf.get_variable("v", [1])
assert x.op.name == "foo/add"

variable_scope主要用在循环神经网络(RNN)的操作中,其中需要大量的共享变量。

1.5.2 name_scope示例

TensorFlow中常常会有数以千计的节点,在可视化的过程中很难一下子展示出来,因此用name_scope为变量划分范围,在可视化中,这表示在计算图中的一个层级。name_scope会影响op_name,不会影响用get_variable()创建的变量,而会影响通过Variable()创建的变量。因此:

with tf.variable_scope("foo"):
    with tf.name_scope("bar"):
        v = tf.get_variable("v", [1])
        b = tf.Variable(tf.zeros([1]), name='b')
        x = 1.0 + v
assert v.name == "foo/v:0"
assert b.name == "foo/bar/b:0"
assert x.op.name == "foo/bar/add"

可以看出,tf.name_scope()返回的是一个字符串,如上述的"bar"。name_scope对用get_variable()创建的变量的名字不会有任何影响,而Variable()创建的操作会被加上前缀,并且会给操作加上名字前缀。

1.6 批标准化

批标准化 (batch normalization,BN)是为了克服神经网络层数加深导致难以训练而诞生的。

我们知道,深度神经网络随着网络深度加深,训练起来会越来越困难,收敛速度会很慢,常常会导致梯度弥散问题 (vanishing gradient problem)

统计机器学习中有一个ICS(Internal Covariate Shift)理论,这是一个经典假设:源域(source domain)和目标域 (target domain)的数据分布 是一致的。也就是说,训练数据和测试数据是满足相同分布的。这是通过训练数据获得的模型能够在测试集获得好的效果的一个基本保障。

Covariate Shift是指训练集的样本数据和目标样本集分布不一致时,训练得到的模型无法很好地泛化 (generalization)。它是分布不一致假设之下的一个分支问题,也就是指源域和目标域的条件概率是一致的,但是其边缘概率不同。的确,对于神经网络的各层输出,在经过了层内操作后,各层输出分布就会与对应的输入信号分布不同,而且差异会随着网络深度增大而加大,但是每一层所指向的样本标记 (label)仍然是不变的。

解决思路一般是根据训练样本和目标样本的比例对训练样本做一个矫正。因此,通过引入批标准化来规范化某些层或者所有层的输入,从而固定每层输入信号的均值与方差。

1.6.1 方法

批标准化一般用在非线性映射(激活函数)之前,对 \(x =Wu +b\)

1.6.2 优点

批标准化通过规范化让激活函数分布在线性区间,结果就是加大了梯度,让模型更加大胆地进行梯度下降,于是有如下优点:

  • 加大探索的步长,加快收敛的速度;
  • 更容易跳出局部最小值;
  • 破坏原来的数据分布,一定程度上缓解过拟合。

因此,在遇到神经网络收敛速度很慢或梯度爆炸 [13] (gradient explode)等无法训练的情况下,都可以尝试用批标准化来解决。

1.6.3 示例

我们对每层的Wx_plus_b进行批标准化,这个步骤放在激活函数之前:

# 计算Wx_plus_b的均值和方差,其中axes=[0]表示想要标准化的维度
fc_mean, fc_var = tf.nn.moments(Wx_plus_b, axes=[0], )
scale = tf.Variable(tf.ones([out_size]))
shift = tf.Variable(tf.zeros([out_size]))
epsilon = 0.001
Wx_plus_b = tf.nn.batch_normalization(Wx_plus_b, 
                                      fc_mean, 
                                      fc_var, 
                                      shift,
                                      scale, 
                                      epsilon)
# 也就是在做:
# Wx_plus_b = (Wx_plus_b - fc_mean) / tf.sqrt(fc_var + 0.001)
# Wx_plus_b = Wx_plus_b * scale + shift

更多关于批标准化的理论可以查看Sergey Ioffe和Christian Szegedy的论文《Batch
Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》