前言
到底是选 TensorFlow 还是 PyTorch?萝卜青菜各有所爱。虽然很多人吐槽 TensorFlow 框架的复杂以及调试代码的痛苦,但选择 TensorFlow 人还是很多。大概,这就是真爱吧!本文作者通过对 TensorFlow 代码进行百般调戏,哦调试,总结了一套让你感觉不那么痛苦的调试方法,趁热围观吧
当谈到在 TensorFlow 上写代码时,我们总会将它和 PyTorch 进行对比,然后讨论 TensorFlow 框架是多么的复杂以及 tf.contrib 的某些部分为什么那么糟糕。此外,我还认识许多数据科学家,他们只用预先写好的、可以克隆的 GitHub 库和 TensorFlow 交互,然后成功使用它们。对 TensorFlow 框架持有这种态度的原因各不相同,想要说清楚的话恐怕还得另外写个长篇,现在我们要关注的是更实际的问题:调试用 TensorFlow 写的代码,并理解其主要特性。
核心概念
计算图。计算图 tf.Graph 让框架能够处理惰性求值范式(不是 eager execution,一种命令式编程环境)。基本上,这种方法允许程序员创建 tf.Tensor(边) 和 tf.Operation(节点),但它们不会立刻进行运算,只有在执行图时才会计算。这种构建机器学习模型的方法在许多框架中都很常见(例如,Apache Spark 中就用了类似的想法),这种方法也有不同的优缺点,这些优缺点在编写和运行代码时都很明显。最主要也是最重要的优点是,数据流图可以在不明确使用 multiprocessing 模块的情况下,实现并行和分布式执行。实际上,写得好的 TensorFlow 模型无需任何额外配置,一启动就可以调用所有核的资源。
但这个工作流程有个非常明显的缺点:只要你在构建图时没提供任何输入来运行这个图,你就无法判断它是否会崩溃。而它很有可能会崩溃。此外,除非你已经执行了这个图,否则你也无法估计它的运行时间。
计算图的主要组成部分是图集合和图结构。严格地说,图结构是之前讨论过的节点和边的特定集合,而图集合则是变量的集合,可以根据逻辑对这些变量进行分组。例如,检索图的可训练变量的常用方法是:tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES)。
会话。它与计算图高度相关,但解释起来却要更复杂一些:TensorFlow 会话 tf.Session 是用来连接客户端程序和 C++运行时的(记住,TensorFlow 是用 C++ 写的)。为什么是 C++呢?因为通过这种语言实现的数学运算很好优化,因此计算图运算可以得到很好的处理。
如果你用的是低级 TensorFlow API(大多数 Python 开发人员使用的都是),那 TensorFlow 会话将会作为上下文管理器调用:使用 with tf.Session() as sess: 句法。如果传递给构造函数的会话没有参数,那么就只会使用本地机器的资源和默认的 TensorFlow 图,但它也可以通过分布式 TensorFlow 运行时使用远程设备。事实上,没有会话,图就不能存在(图没有会话就无法执行),而且会话一般都有一个指向全局图的指针。
更深入地研究运行会话的细节,值得注意的要点是它的句法:tf.Session.run()。它可以将张量、运算或类似张量的对象作为参数(或参数列表)提取。此外,feed_dict(这个可选参数是 tf.placeholder 对象到其值的映射)可以和一组选项一起传递。
可能遇到的问题及其解决方案
通过预训练模型加载会话并进行预测。这是一个瓶颈,我花了好几周来理解、调试和修改这个问题。我高度关注这个问题,并提出了两个重新加载和使用预训练模型(图和会话)的技巧。
首先,我们谈到加载模型时我们真正的意思是什么?当然,为了实现这一点,我们需要先训练和保存模型。后者一般是通过 tf.train.Saver.save 功能实现的,因此,我们有三个二进制文件,它们的扩展名分别是 .index,.m*e*ta 和 .data-00000-of-00001,这其中包含了还原会话和图所需的所有数据。
为了加载以这种方式保存的模型,首先要通过 tf.train.import_meta_graph()(参数是扩展名为 .meta 的文件)还原图。按照前面的描述操作后,会将所有变量(包括后面将会提到的「隐藏」变量)传入当前的图中。执行 graph.get_tensor_by_name 来检索具有名称的张量(记住,由于张量的创建范围和运算,它可能和你初始化后的那个不同)。这是第一种方法。
第二种方法更明确,但是也更难实现(我一直都在研究模型架构,但我从没成功地用这种方法执行图),这种方法的主要思路是在 .npy 或 .npz 文件中明确地存储图的边(张量),之后再将它们加载回图中(同时根据它们的创建范围给它们分配恰当的名称)。这种方法有两个巨大的缺点:首先,当模型架构变得非常复杂时,控制和保持所有的权重矩阵也变得很难。其次,还有一类「隐藏」张量,它们是在没有明确初始化的情况下创建的。例如,当你创建 tf.nn.rnn_cell.BasicLSTMCell 时,它为了实现 LSTM 单元,会偷偷创建所有必需的权重和偏差。变量名称也是自动分配的。
这种行为看似没什么问题(只要这两个张量是权重,且它们是用框架处理而非手动创建的),但是事实上,在许多情况下都并非如此。该方法的主要问题是当你看图的集合时,你也会看到一大堆来源不明的变量,实际上你并不知道应该把什么保存下来,也不知道应该从哪加载它。坦率地讲,将隐变量放在图中正确的位置并恰当地操作是很难的。这比你本身的需求还要难。
在没有任何警告的情况下创建了两个名字相同的张量(通过自动添加_index 结尾)。我认为这个问题并不像前面那个那么重要,但它造成的大量图运算错误问题也确实给我带来了困扰。为了更好地解释这个问题,我们来看个例子。
例如,你用 tf.get_variable(name=’char_embeddings‘, dtype=…) 创建了张量,然后将它保存下来,并在新的会话中加载它。你忘了这个变量是可训练的,然后通过 tf.get_variable() 又以同样的方式创建了一次。在图执行期间,会报这样的错:FailedPreconditionError (see above for traceback): Attempting to use uninitialized value char_embeddings_2。发生这个错误的原因是,你已经创建了一个空变量但没有把它放在模型中合适的地方,而只要它在图中,就可以进行传输。
你可能没见过开发人员因为创建了两个名字相同的张量(即便是 Windows 也会这么做)而引发任何错误或警告。也许这一点只是对我而言很重要,但这是 TensorFlow 的特点,而且是我很不喜欢的一点。
在写单元测试还有一些其他问题时要手动重置图形。由于一些原因,很难测试用 TensorFlow 写的代码。第一个——也是最明显的一点在本段开头已经提到了,这听起来可能很傻,但对我来说,它太令人恼火了。举个例子,由于在运行时访问的所有模块的所有张量只有一个默认的 tensorflow 图,因此无法在不重置图的情况下用不同的参数测试相同的功能。虽然 tf.reset_default_graph() 写成代码只有一行,但是它要写在大多数方法的顶部,这个解决方法变成了重复性的工作,即明显的复制代码。我没发现任何可以解决这个问题的方法(除了使用范围的 reuse 参数,这个会在后面讨论),只要将所有张量链接到默认图即可,但是没有方法可以将它们分隔开(当然,每种方法都可以用单独的 TensorFlow 图,但在我看来,它们都不是最佳实现)。
关于 TensorFlow 代码的单元测试问题也让我困扰已久:当不需要执行构建图的一部分(因为模型尚未训练所以其中有未初始化的张量)时,我不知道应该测试些什么。我的意思是 self.assertEqual() 的参数不清楚(我们是否要测试输出张量的名字或形状?如果形状是 None 呢?如果仅凭张量名称或形状无法推断代码是否运行良好呢?)。就我个人而言,我只是简单地测试了张量的名称、形状和维度,但我确信,在一些没有执行图的情况中,只检查这部分功能并不合理。
令人困惑的张量名称。许多人可能认为这样评价 TensorFlow 的性能不太好,但有时没人说得出来在执行某些操作后得到的张量名称是什么。举个例子,你知道 bidirectional_rnn/bw/bw/while/Exit_4:0 是什么意思吗?对我来说,这简直莫名其妙。我知道这个张量是对动态双向 RNN 的后向单元进行某种运算得到的结果,但如果没有明确地调试代码,你就无法得知到底是按什么样的顺序执行了什么样的运算。此外,索引的结尾也令人无法理解,如果想知道数字 4 来自哪里,你得阅读 TensorFlow 文档并深入研究计算图。
对前面讨论过的「隐」变量来说,情况也是一样的:为什么我们会有 bias 和 kernel 的名称呢?也许这是我的资历和技术水平问题,但对我来说这样的调试情况是很不自然的。
tf.AUTO_REUSU 是可训练变量,可以重新编译库和其他不好的东西。这部分的最后一点是简要介绍我通过错误和尝试方法学到的一些小细节。首先是范围的参数 reuse=tf.AUTO_REUSE,它允许自动处理已经创建的变量,如果这些变量已经存在的话就不会进行二次创建。事实上,在许多情况下,它都可以解决本段提出的第二个问题。但在实际情况中,只有当开发人员知道代码的某些部分需要运行两次或两次以上时,才应该谨慎地使用这一参数。
第二点是关于可训练变量,这里最重要的点是:默认情况下所有张量都是可训练的。有时候你可能不需要对其进行训练,而且很容易会忘记它们都可以训练。这一点有时令人头疼。
第三点只是一个优化技巧,我建议每个人都这么做:几乎在所有情况下,当你使用通过 pip 安装的软件包时,会收到如下警告:Your CPU supports instructions that this TensorFlow binary was not compiled to use: AVX AVX2。如果看到这类信息,最好卸载 TensorFlow,再根据你需要的选项通过 bazel 重新编译它。这样做的主要好处是可以提升计算速度,而且可以更好地提高框架的总体性能。
总结
希望本文能够帮助那些首次开发 TensorFlow 模型的数据科学家。他们可能正挣扎于框架的某些部分,这些部分很难理解而且调试起来很复杂。我想说的是,不要担心在使用这个库时犯很多错误(也别担心其他的),只要提出问题,深入研究官方文档,调试出错的代码就可以了。
这些与跳舞或者游泳一样,都需要熟能生巧,我希望能够让这种练习变得更愉快也更有趣一些