论智
首发于论智
进了谷歌门才领悟的Tensorflow教程:答疑解惑(一)

进了谷歌门才领悟的Tensorflow教程:答疑解惑(一)

作者:Jacob Buckman

编译:Bot

编者按:2017年夏季,CMU CS硕士生Jacob Buckman入选Google AI居留计划,在谷歌总部开启了自己为期12月的培训生活,主攻NLP和强化学习。Jacob拥有丰富的编程经验,而且在机器学习上也造诣颇多。虽然从未接触过Tensorflow,但他相信依靠自己的学识背景,掌握一个工具是很轻松的一件事。很可惜,现实打了他的脸……


自发布三年来,Tensorflow已经成为深度学习生态系统的基石,然而相比PyTorch、DyNet这样基于动态图“define-by-run”的库,它对初学者来说却并不直观。

从线性回归、MNIST分类到机器翻译,Tensorflow的教程无处不在,它们是帮助新手开启项目的优质资源,也是新人接触机器学习的敲门砖。但对于机器学习还未涉足的空白领域,如果开发者想做一些原创性的突破,Tensorflow可能会让他们望而生畏。

本文的目的是填补这一领域的空白,文章将紧紧围绕一般方法,并解释支撑Tensorflow的基本概念,而不是专注于某个特定任务。掌握这些概念后,开发者可以更直观地用Tensorflow进行深度学习研究。

注:本教程适合在编程和机器学习上有一定经验,且必须要用到Tensorflow的开发者。

Tensorflow不是一个普通的Python库

大多数Python库其实是Python的扩展。当你导入一个库时,你得到的是一组变量、函数和类,它们实际上只是充当代码的“工具箱”,满足开发者的现实需要。但Tensorflow不是。如果我们一开始就抱着如何和代码交互的想法去研究Tensorflow,那就相当于在本质上走入歧途。

要说Python和Tensorflow之间的关系,我们可以把它简单类比成Javascript和HTML。Javascript是一种用途广泛的编程语言,我们可以用它实现很多东西。而HTML是一个框架,可以表示一些抽象计算(比如描述网页上呈现的内容)。当用户打开一个网页时,Javascript的作用是使他看到HTML对象,并且在网页迭代时用新的HTML对象代替旧的对象。

和HTML类似,Tensorflow也是一个用于表示抽象计算的框架。当我们用Python操作Tensorflow时,代码做的第一件事是组装计算图,第二件事是和计算图进行交互(Tensorflow里的会话sessions)。但计算图不在变量内部,而在全局名称空间中。正如莎士比亚当年说过:所有RAM都是一个阶段,所有变量都只是指针。

第一个关键概念:计算图

在浏览Tensorflow文档时,你会发现其中有大量关于“graphs”和“nodes”的描述。如果足够细心,也许你也已经在图和会话这个页面找到了所有关于数据流图的详细介绍。这个页面的内容是我们下文要重点解释的,不同的是,官方文档的表述充满“技术感”,而我们会牺牲一些技术细节,重点捕捉其中的直觉。

那么什么是计算图?事实上,计算图表示的是全局数据结构:它一个有向图,包含数据计算流程的所有信息。

我们先来看一个示例:

import tensorflow as tf

计算图

导入Tensorflow后,我们得到了一幅空白的计算图,表示一个孤立的、空白的全局变量。在这个基础上,我们再进行一些“Tensorflow操作”:

代码

import tensorflow as tf
two_node = tf.constant(2)
print two_node

输出

Tensor("Const:0", shape=(), dtype=int32)

计算图

这里我们得到了一个节点(node),它包含常数2,这个2是函数tf.constant带来的。当我们print变量时,可以看到它返回了一个tf.Tensor对象,它是我们刚创建的节点的指针。为了验证这一点,这里是另一个例子:

代码

import tensorflow as tf
two_node = tf.constant(2)
another_two_node = tf.constant(2)
two_node = tf.constant(2)
tf.constant(3)

计算图

即便前后函数功能一致,即便这些函数只是简单地给同一个对象重复赋值,甚至即便它们根本没有被分配给变量,对于每次调用函数tf.constant,计算图中都会创建一个新节点。

相反地,如果我们创建了一个新变量,并把它赋值一个存在的节点,这就相当于把指针复制到该节点,这时计算图上是不会出现新节点的:

代码

import tensorflow as tf
two_node = tf.constant(2)
another_pointer_at_two_node = two_node
two_node = None
print two_node
print another_pointer_at_two_node

输出

None
Tensor("Const:0", shape=(), dtype=int32)

计算图

接下来,我们尝试一些有趣的东西:

代码

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node ## 相当于tf.add(two_node, three_node)

计算图

上图已经是一幅真正意义上的计算图了。需要注意的是,TensorFlow对常见数学运算符进行了重载,比如上面的tf.add。虽然它表面上没有新增节点,但它确实把两个张量一起添加进了一个新节点。

所以two_node指向包含2的节点,three_node指向包含3的节点,sum_node指向包含+的节点——是不是觉得有些不寻常,为什么sum_node里会是+,而不是5呢?

因为计算图只包含步骤,不包含结果!至少……现在还不包含!

第二个关键概念:会话

如果说TensorFlow中存在bug重灾区,那会话(session)一定排名首位。由于缺乏明确的命名,再加上函数的通用性,几乎每个Tensorflow程序都要调用不止一次tf.Session()

会话的作用是管理程序运行时的所有资源,如内存分配和优化,以便我们能按照计算图的指示进行实际计算。你可以把计算图想象成计算“模板”,上面列出了所有详细步骤。所以每次在启动计算图前,我们都要先进行一个会话,分配资源,完成任务;在计算结束后,我们又得关闭会话来帮助系统回收资源,防止资源泄露。

会话包含一个指向全局的指针,这个指针会基于计算图中所有指向节点的指针不断更新。这意味着会话和节点的创建不存在时间先后问题。

创建会话对象后,我们可以用sess.run(node)返回节点的值,并且Tensorflow会执行确定该值所需的所有计算。

代码

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node
sess = tf.Session()
print sess.run(sum_node)

输出

5

计算图

我们也可以写成sess.run([node1, node2,...]),让它返回多个输出:

代码

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node
sess = tf.Session()
print sess.run([two_node, sum_node])

输出

[2, 5]

计算图

一般来说,sess.run()是TensorFlow的最大瓶颈,你用的越少,程序就越好。只要有可能,我们应该让它一次性输出多个结果,而不是频繁使用,千万不要把它放进复杂循环。

占位符和feed_dict

到目前为止,我们做的计算没有输入,所以一直得到相同的输出。下面我们会进行更有意义的探索,比如构建一个能接受输入的计算图,让它经过某种方式的处理,最后返回一个输出。

要做到这一点,最直接的方法是使用占位符(Placeholders),这是一种用于接受外部输入的节点。

代码

import tensorflow as tf
input_placeholder = tf.placeholder(tf.int32)
sess = tf.Session()
print sess.run(input_placeholder)

输出

Traceback (most recent call last):
...
InvalidArgumentError (see above for traceback): You must feed a value for placeholder tensor 'Placeholder' with dtype int32
     [[Node: Placeholder = Placeholder[dtype=DT_INT32, shape=<unknown>, _device="/job:localhost/replica:0/task:0/device:CPU:0"]()]]

计算图

这是一个典型的失败案例,因为占位符本身没有初始值,再加上我们没有对它赋值,Tensorflow出现了个bug。

在会话sess.run()中,占位符可以用feed_dict馈送数据。

代码

import tensorflow as tf
input_placeholder = tf.placeholder(tf.int32)
sess = tf.Session()
print sess.run(input_placeholder, feed_dict={input_placeholder: 2})

输出

2

计算图

注意feed_dict的格式,它是一个字典,对于计算图中所有存在的占位符,它都要给出相应的取值(如前所述,它其实是指向图中占位符节点的指针),这些值一般是标量或Numpy数组。

第三个关键概念:计算路径

让我们试试另一个涉及占位符的例子:

代码

import tensorflow as tf
input_placeholder = tf.placeholder(tf.int32)
three_node = tf.constant(3)
sum_node = input_placeholder + three_node
sess = tf.Session()
print sess.run(three_node)
print sess.run(sum_node)

输出

3
Traceback (most recent call last):
...
InvalidArgumentError (see above for traceback): You must feed a value for placeholder tensor 'Placeholder_2' with dtype int32
     [[Node: Placeholder_2 = Placeholder[dtype=DT_INT32, shape=<unknown>, _device="/job:localhost/replica:0/task:0/device:CPU:0"]()]]

计算图

这里又出错了,那么为什么第二个sess.run这次出现bug了呢?为什么我们没有评估input_placeholder,最后却引发了一个关于它的错误?这两个问题的答案就在于Tensorflow的第三个关键概念:计算路径。好在这块内容总体比较直观。

当我们调用sess.run()时,我们计算的不只是当前节点,还有一些和它相关的节点的值。如果这个节点依赖于其他节点,那我们就要一步步上溯计算,直到达到计算图的“顶端”,也就是不再有其他节点会对目标节点施加影响。

下图是sum_node节点的计算路径:

为了计算sum_node,我们要评估所有三个节点的值,其中包括我们没有赋值的占位符,这解释了出现错误的原因。

相反地,three_node的计算路径比较单一:

只要评估一个节点就够了,所以即便input_placeholder没有赋值,它也不会对sess.run(three_node)造成影响。

Tensorflow的框架优势离不开计算路径设计。想象一下,如果我们手里有一幅巨型计算图,其中包含大量不必要的节点,通过这样的计算方式,我们可以绕过大多数点,只计算必要内容,这就为节省大量运行时间提供了可能性。此外,它还允许我们构建大型的“多用途”计算图,这些图中可以有一些共享的核心节点,但我们可以通过不同计算路径来进行不同的计算。

变量和副作用

截至目前,我们接触了两种“没有祖先”的节点:tf.constanttf.placeholder。其中前者每轮都是一个定值;后者每轮都不一样。除此之外,我们还需要考虑第三种情况:它可以连续几轮都是个定值,但如果出现了一个新值,它也可以更新。这就是我们要引入的变量(Variables)概念。

如果想用Tensorflow进行深入学习,了解变量至关重要,因为模型的参数基本上都是变量。在训练期间,我们会用梯度下降更新参数;但在评估过程中,我们却要保持参数不变,并将大量不同的测试集输入模型中。所以如果有可能的话,我们会希望所有可训练的参数都是变量。

创建变量的方法是tf.get_variable(),其中前两个参数tf.get_variable(name, shape)是固定的,剩余的都是可选参数。name是标识变量对象的字符串,它必须是独一无二的,要确保没有重复名称。shape是与张量形状对应的整数矩阵,它按顺序排列,每个维度只有一个整数,例如一个3×8矩阵的shape应该是[3, 8]。如果创建的是标量,记得符号是[]。

代码

import tensorflow as tf
count_variable = tf.get_variable("count", [])
sess = tf.Session()
print sess.run(count_variable)

输出

Traceback (most recent call last):
...
tensorflow.python.framework.errors_impl.FailedPreconditionError: Attempting to use uninitialized value count
     [[Node: _retval_count_0_0 = _Retval[T=DT_FLOAT, index=0, _device="/job:localhost/replica:0/task:0/device:CPU:0"](count)]]

计算图

又出问题了,这次又是为什么呢?当我们首次创建变量时,它的初始值是“Null”,这时评估它都是会出bug的。变量要先赋值,再评估。这里赋值的方法有两种,一是设定一个初始值,二是tf.assign()。我们来看tf.assign()

代码

import tensorflow as tf
count_variable = tf.get_variable("count", [])
zero_node = tf.constant(0.)
assign_node = tf.assign(count_variable, zero_node)
sess = tf.Session()
sess.run(assign_node)
print sess.run(count_variable)

输出

0

计算图

和上文提到的节点相比,tf.assign(target, value)这个节点有点特殊:

  • 它不做计算,总是等于value
  • 副作用(Side Effects)。上图显示了这个操作的副作用,当计算流通过assign_node时,count_variable节点里的值被强行替换成了zero_node节点的值;
  • 即便count_variable节点和assign_node之间存在连接,但两者互不依赖(虚线)。

因为“副作用”节点支撑着大部分Tensorflow深度学习计算流,所以真正理解其中的原理是很有必要的,当我们运行sess.run(assign_node)时,计算路径经过assign_nodezero_node

计算图

之前提到了,我们计算目标节点时会一起计算和它相关的节点,这之中包括副作用。如上图中的绿色部分所示,由于tf.assign带来的特定副作用,原先储存“Null”的count_variable现在已经被永久设置成了0,这意味着下次我们调用sess.run(count_variable)时,它会输出0,而不是反馈bug。

接下来,让我们看看设定初始值:

代码

import tensorflow as tf
const_init_node = tf.constant_initializer(0.)
count_variable = tf.get_variable("count", [], initializer=const_init_node)
sess = tf.Session()
print sess.run([count_variable])

输出:

Traceback (most recent call last):
...
tensorflow.python.framework.errors_impl.FailedPreconditionError: Attempting to use uninitialized value count
     [[Node: _retval_count_0_0 = _Retval[T=DT_FLOAT, index=0, _device="/job:localhost/replica:0/task:0/device:CPU:0"](count)]]

计算图

好的,为什么这里又没有初始化呢?

答案在于会话和计算图之间的割裂。我们为变量设置了一个初始值const_init_node,但它反映在计算图上却只是两个节点间的虚线连接。这是因为我们在会话中根本没有初始化操作,没有为它分配计算资源。我们需要在会话中把变量更新成const_init_node

代码

import tensorflow as tf
const_init_node = tf.constant_initializer(0.)
count_variable = tf.get_variable("count", [], initializer=const_init_node)
init = tf.global_variables_initializer()
sess = tf.Session()
sess.run(init)
print sess.run(count_variable)

输出

0.

计算图

为此,我们添加了另一个特殊节点:init = tf.global_variables_initializer()。和tf.assign()类似,这也是一个带有副作用的节点,但它不需要指定输入内容。tf.global_variables_initializer()从创建之初就纵观全图,并自动为图中的每个tf.initializer添加依赖关系。当我们开始执行sess.run(init)时,它会完成全图初始化,从而避免报错。

变量共享

在实际操作中,有时我们也会遇到Tensorflow代码与变量共享,它涉及创建范围并设置“reuse = True”,但我们强烈不建议你这么做。如果你想在多个地方使用单个变量,只需以编程方式跟踪指向该变量节点的指针,并在需要时重新使用它。换句话说,对于你打算存储在内存中的每个参数,你应该只调用一次tf.get_variable()

优化

解决了上述拦路虎,现在我们就能进行真正的深度学习了!如果你读到了这里,剩下的概念对你来说应该是非常简单的。

在深度学习中,一个典型的“inner loop”(内循环)是这样的:

  • 获得input和true_output
  • 根据输入和参数计算一个估计值
  • 对比true_output和估计值,计算模型loss
  • 根据loss梯度更新参数

下面是线性回归问题的一个简单脚本:

代码

import tensorflow as tf

### 构建计算图
## 首先设置参数
m = tf.get_variable("m", [], initializer=tf.constant_initializer(0.))
b = tf.get_variable("b", [], initializer=tf.constant_initializer(0.))
init = tf.global_variables_initializer()

## 其次设置计算
input_placeholder = tf.placeholder(tf.float32)
output_placeholder = tf.placeholder(tf.float32)

x = input_placeholder
y = output_placeholder
y_guess = m * x + b

loss = tf.square(y - y_guess)

## 最后设置优化器和最小化节点
optimizer = tf.train.GradientDescentOptimizer(1e-3)
train_op = optimizer.minimize(loss)

### 启动会话
sess = tf.Session()
sess.run(init)

### 执行训练循环
import random

## 提出一个问题
true_m = random.random()
true_b = random.random()

for update_i in range(100000):
  ## (1) 获得输入和输出
  input_data = random.random()
  output_data = true_m * input_data + true_b

  ## (2), (3), (4) 一起调用一个sess.run()!
  _loss, _ = sess.run([loss, train_op], feed_dict={input_placeholder: input_data, output_placeholder: output_data})
  print update_i, _loss

### 最后,print计算得出的两个参数的更新值
print "True parameters:     m=%.4f, b=%.4f" % (true_m, true_b)
print "Learned parameters:  m=%.4f, b=%.4f" % tuple(sess.run([m, b]))

输出

0 2.3205383
1 0.5792742
2 1.55254
3 1.5733259
4 0.6435648
5 2.4061265
6 1.0746256
7 2.1998715
8 1.6775116
9 1.6462423
10 2.441034
...
99990 2.9878322e-12
99991 5.158629e-11
99992 4.53646e-11
99993 9.422685e-12
99994 3.991829e-11
99995 1.134115e-11
99996 4.9467985e-11
99997 1.3219648e-11
99998 5.684342e-14
99999 3.007017e-11
True parameters:     m=0.3519, b=0.3242
Learned parameters:  m=0.3519, b=0.3242

我们可以看到,loss基本没变,而且我们很好地计算出了更新参数。其中这部分是我们新接触的内容:

## 最后设置优化器和最小化节点
optimizer = tf.train.GradientDescentOptimizer(1e-3)
train_op = optimizer.minimize(loss)

第一行optimizer = tf.train.GradientDescentOptimizer(1e-3)的作用是创建一个包含辅助函数的Python对象,而不是把节点添加进计算图。第二行train_op = optimizer.minimize(loss)的作用是一个节点添加到图中,并将一个指针存储在变量train_op中。

这个train_op节点没有输出,但有非常复杂的副作用:它会追溯输入数据loss的计算路径,查找变量节点。对于找到的每个变量节点,它计算该变量相对于loss的梯度,然后用“当前值减去梯度乘以学习率”计算变量更新值。最后,它再执行一个赋值操作来更新变量的值。

所以当我们调用sess.run(train_op)时,所有变量已经完成梯度下降。当然,我们还需要用feed_dict填充输入和输出占位符,并print这些loss,以便后续调试。

tf.Print调试

既然我们已经学会用Tensorflow做一些复杂的事,那么这时debug是不可避免的。一般来说,我们很难从计算图上发现什么错——因为sess.run()直接输出会话最终结果,我们没法接触到过程中的计算值,也没法调用常规Python语句。简而言之,在调用sess.run()前,这些值还不存在;执行sess.run()后,这些值就消失了!

我们来看一个简单的例子:

代码:

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node
sess = tf.Session()
print sess.run(sum_node)

输出:

5

2加3是等于5,但如果我们想要检查的是two_nodethree_node这两个值,又该怎么办?对此,一种可行的解决方法是在sess.run()中为每个要检查的节点添加返回参数,然后输出这些参数。

代码

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node
sess = tf.Session()
answer, inspection = sess.run([sum_node, [two_node, three_node]])
print inspection
print answer

输出

[2, 3]
5

一般情况下,这种方法是可行的,但随着代码变得越来越复杂,它就不太便利了。一种更简单的方法是直接用tf.Print声明。这里有个奇怪的地方,tf.Print实际上是一种Tensorflow节点,它具有输出和副作用!它有两个必需的参数:要复制的节点和要输出的内容列表。其中“要复制的节点”可以是计算图中的任意节点,副作用则是输出“内容列表”中的所有当前值。

代码

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node
print_sum_node = tf.Print(sum_node, [two_node, three_node])
sess = tf.Session()
print sess.run(print_sum_node)

输出

[2][3]
5

计算图

这里有一个重点,和其他副作用一样,print仅在计算流经过tf.Print节点时才会发生,也就是如果tf.Print不在计算路径内,或者即便tf.Print复制的节点在计算路径内,但它本身不在,print都是不会执行的。所以如果要复制某个节点,记得即时创建tf.Print节点。

代码

import tensorflow as tf
two_node = tf.constant(2)
three_node = tf.constant(3)
sum_node = two_node + three_node
### 这个two_node的新副本不在计算路径上,所以最后没有print
print_two_node = tf.Print(two_node, [two_node, three_node, sum_node])
sess = tf.Session()
print sess.run(sum_node)

输出

5

计算图

关于更多debug,这里是一个很好的资源,值得一看。

希望这篇文章能够帮助你更好地理解Tensorflow是什么、它是如何工作的,以及如何使用它。

发布于 2018-06-28

文章被以下专栏收录

    专注于人工智能新技术、新应用 【公众号:论智 (jqr_AI) 】