C++运行TensorFlow模型

这里说的C++运行TensorFlow模型指的是用纯C++代码,实现用训练好的TensorFlow模型来预测末知数据。对如何让iOS、Android运行Tensroflow模型的一系列问题中,它最为核心,因为那些系统基本不可能提供python的运行环境。

为更好理解这问题,最好把它放到更大范围,然后去了解它在整系统中角色。《用Rose构建需要TensorFlow的跨平台app》在更高角度描述了app使用TensorFlow,它具体对应那文章中“移动app中的TensorFlow”节“2)按模型的输入要求,根据当前问题生成输入参数,3)基于模型创建会话,运行会话,根据模型对输出的定义得到当前问题的识别结果”。当然,文章是把TensorFlow内置到了Rose,但这里说的解决步骤适用其它想在终端app使用TensorFlow的场合。

我把“C++运行TensorFlow模型”分为五个部分:保存模型、C++运行模型、准备输入张量、处理输出张量和保存训练出的模型。

一、保存模型

要使用模型首先要定义它,然后训练,接着保存成C++可运行格式。要没意外,这些都是用python实现,然后在高性能PC上运行。

import tensorflow as tf;
from tensorflow.python.framework import graph_util;

def inference(input_sensor):
	bais = tf.Variable(tf.constant(3.0, shape=[1]));
	result = tf.add(input_sensor, bais, name = 'result');

input_sensor = tf.placeholder(tf.float32, [1, 3, 2, 1], name='sensor');
inference(input_sensor);

with tf.Session() as sess:
	tf.global_variables_initializer().run();
	graph_def = tf.get_default_graph().as_graph_def();
	output = graph_util.convert_variables_to_constants(sess, graph_def, ['result']);
	with tf.gfile.GFile("/mnt/model/combined_model.pb", "wb") as f:
	 	f.write(output.SerializeToString());

app只关心模型的预测部分,即前向传播算法。示例中的“def inference”模拟了预测函数,input_sensor是输入张量,针对识别图像往往是个四维张量(以上代码用了[1, 3, 2, 1])。第一维是batch,对app来说,一次只需预测一张,因而用1。第二维是图像高度,代码是3。第三维是图像宽度,代码中2。第四维是图像深度,即表示一个像素需要的字节数,代码是1,1表示是灰度图像,RGB三色时是3。inference中的“bais”表示了偏置项,它是模型参数,是变量。实际问题中参数除了偏置项还有权重(weight),它们都是要被训练的。result是输出张量,而且会被要求上传到app,要填写好它的节点名称,第一个原因是模型和app之间是靠名称来识别特定张量,这点接下的“二、C++运行模型”会有补充。第二个原因则和convert_variables_to_constants第三个参数有关

为减少输入数据在计算图中占用的节点数,python代码会用tf.placehloader机制提供输入数据。示例正是用该机制提供inference须要的input_sensor。这里要注意,placeholder参数中的name值,原因依旧是模型和app之间是靠名称来识别特定张量。

“使用tf.train.Saver会保存运行TensorFlow程序所需要的全部信息,然而有时并不需要某些信息。比如在测试或者离线预测时,只需要知道如何从神经网络的输入层经过前向传播计算得到输出层即可,而不需要类似于变量初始化、模型保存等辅助节点信息。……而且,将变量取值和计算图结构分成不同的文件存储有时候也不方便,于是TensorFlow提供了convert_variables_to_constants函数,通过这个函数可以将计算图中的变量及其取值通过常量的方式保存,这样整个TensorFlow计算图就可以统一存放在一个文件中”《TensorFlow:实战Google深度学习框架》。这个文件就是C++可运行的模型文件,对应的顶层类是GraphDef,而不是MetaGraphDef。

注意convert_variables_to_constants第三个参数,它是须要保存的节点名称。示例中“result”节点对应“result”,计算该张量等于执行了整个预测过程。

运行上面python代码就能生成combined_model.pb,然后把这pb放到app运行时可访问的目录。

实际使用时参数(权重和偏置)是要被训练的,不可能是示例中的一次性赋值。后面会说如何用训练后模型生成pb,但为连惯先讲完整个过程。有了pb后,接下就是C++运行该pb。

二、C++运行模型

用Rose构建需要TensorFlow的跨平台app》有说如何加载模型到内存,操作适用于任何模型,调用上也直观,这里不再补充。加载后是运行,运行调用Run函数。

Status Run(const std::vector<std::pair<string, Tensor> >& inputs,
    const std::vector<string>& output_tensor_names, 
    const std::vector<string>& target_node_names, 
    std::vector<Tensor>* outputs);

针对示例,调用是以下语句。“三、准备输入张量”会说如何生成image_tensor,“四、处理输出张量”会说如何处理outputs。

std::vector<tensorflow::Tensor> outputs;
run_status = session->Run({{"sensor", image_tensor}}, {"result"}, {}, &outputs);

函数功能是用inputs提供的张量执行预测(前向传播算法),并把表示预测结果的张量存放在outputs。参数中inputs、output_tensor_names、target_node_names都是输入,只有outputs是输出。

  • inputs[INPUT]。它是一个vector,每个单元是(名称、张量)对,对应GraphDef中的某个输入张量,注意,名称不是张量名称,是节点名称,即如果是“add:0”的话,它只是前面的“add”部分。换个更容易方法,用的是tf.placeholder指定张量时,名称就是name参数的值。
  • output_tensor_names[INPUT]。虽然有tensor字眼,但它不是张量名称而是节点名称,指定的名称次序决定了outputs中的张量次序。一旦返回OK,output_tensor_names尺寸一定等于outputs尺寸,否则outputs内容是未定义。对如何填这个名称,较多使用的方法就是对应convert_variables_to_constants第三个参数中的某个要保存的节点名称。
  • target_node_names[INPUPT]。tensorflow文档对它的解释是“Runs to but does not return Tensors in 'target_node_names'(存储着运行但不返回的节点)”,我还没理解它的意义,但好像传“{}”就行了。
  • outputs[OUT]。输出张量,往往是convert_variables_to_constants第三个参数对应的一个或多个张量。更多见上面对“output_tensor_names”的注释。

Run执行完,C++和模型交互的过程其实已经结束了。但考虑到当中一种全新的数据结构:Tensor,有必要说下如何根据C++常见的一维数组生成输入张量,以及如何把输出张量转成一维数组。

三、准备输入张量

tensorflow::Tensor image_tensor(tensorflow::DT_FLOAT, tensorflow::TensorShape({1, 3, 2, 1}));
auto image_tensor_mapped = image_tensor.tensor<float, 4>();
float* out = image_tensor_mapped.data();

首先是构建一个类型是DT_FLOAT、四个维度[1, 3, 2, 1]的张量对象。为处理方便,张量可能会把内中数据分块存放,但此时外部向它填充像素数据时以一维数组格式更方便,image_tensor.tensor<float, 4>()于是就把内部数据“映射”成float数组。“映射”后,image_tensor_mapped.data()指向这个数组的起始地址。接下C++就可以认为out就是个通常的float数组,该数组长度是1x3x2x1。

注意out要求的数据格式,对图像来说,第一维往往是1,后面依次是高度、宽度、深度,那一维数组中字节是以下格式。

R00 G00 B00 R01 G01 B01.....R10 G10 B10 R11 G11 B11.....

R00表示是第0行第0列的R分量,B12表示第1行第2列的B分量。这个排序次序就是常见的以“Z”扫描图像存储出的字节格式。

四、处理输出张量

tensorflow::Tensor* output = &outputs[0];
const Eigen::TensorMap<Eigen::Tensor<float, 1, Eigen::RowMajor>, Eigen::Aligned>& prediction = output->flat<float>();
const long count = prediction.size();
for (int i = 0; i < count; ++i) {
	const float value = prediction(i);
	// value是该张量以一维数组表示时在索引i处的值。
}

output->flat<float>()把可能多维的张量转成一维数组。prediction.size()是数组长度,prediction(i)则是i索引处的单元值。

五、保存训练出的模型

saver.save(sess, "/mnt/model-10000");

假设训练过程是调用上面语句,然后mnt目录会生成以下三个文件。

model-10000.data-00000-of-00001
model-10000.index
model-10000.meta

接下任务就是让从这三个文件生成pb模型。

import tensorflow as tf;
from tensorflow.python.framework import graph_util;

saver = tf.train.import_meta_graph("/mnt/model-10000.meta");

with tf.Session() as sess:
	saver.restore(sess, "/mnt/model-10000");

	sess.run(tf.assign(tf.get_default_graph().get_tensor_by_name("layer1/weights:0"), tf.get_default_graph().get_tensor_by_name("layer1/weights/ExponentialMovingAverage:0")));
	sess.run(tf.assign(tf.get_default_graph().get_tensor_by_name("layer1/biases:0"), tf.get_default_graph().get_tensor_by_name("layer1/biases/ExponentialMovingAverage:0")));
	sess.run(tf.assign(tf.get_default_graph().get_tensor_by_name("layer2/weights:0"), tf.get_default_graph().get_tensor_by_name("layer2/weights/ExponentialMovingAverage:0")));
	sess.run(tf.assign(tf.get_default_graph().get_tensor_by_name("layer2/biases:0"), tf.get_default_graph().get_tensor_by_name("layer2/biases/ExponentialMovingAverage:0")));	
	sess.run(tf.assign(tf.get_default_graph().get_tensor_by_name("batch-size:0"), [1.0]));

	graph_def = tf.get_default_graph().as_graph_def();
	output = graph_util.convert_variables_to_constants(sess, graph_def, ['layer2/add']);
	with tf.gfile.GFile("/mnt/combined_model-10000.pb", "wb") as f:
	 	f.write(output.SerializeToString());

model-10000.meta存储着计算图结构信息,import_meta_graph执行加载这个图。这时变量未初始化,restore把保存model-10000时的变量值赋给相应变量(相应地完成了初始化)。后面四个变量赋值语句涉及到滑动平均。在TensorFlow,每个变量的滑动平均值是通过影子变量维护的,所以要获取变量的平均值实际上就是获取这个影子变量的取值。四个赋值语句作用就是把最后的滑动平均值赋给变量。当中是什么变量名、以及有多少个变量,要由具体模型决定。示例分了两层,分别命名为“layer1”、“layer2”,每一层各有两个变量,表示权重的“weight”和表示偏置项的“biases”。

在训练时,batch尺寸一般不会是1,修改batch-size变量是为了把batch长度改到app预测需要的1。通常情况下,训练和app预测会用同样的inference逻辑,有些模型在写inference时不得不“显示”使用batch,这时为兼顾训练和app预测,就不能把这batch值设为常量,于是改为使用一个叫batch-size的变量。

pool_shape = pool2.get_shape().as_list();
nodes = pool_shape[1] * pool_shape[2] * pool_shape[3];
reshaped = tf.reshape(pool2, [batch_size, nodes]);

以上代码使用场景是CNN的卷积层到全连接层。pool2是卷积层的输出张量,维度[100, 7, 7, 64],全连接层须要个二维向量[100, 3136],即须要把[7, 7, 64]拉直成[3136]。tf.reshape执行这个维度转换,在转换时不得不“显示”使用batch_size。注:此时pool_shape[0]存储着batch尺寸。以下是定义batch_size变量的代码,BATCH_SIZE是训练时的batch尺寸。

batch_size = tf.Variable(tf.constant([BATCH_SIZE * 1.0]), name='batch-size', trainable=False);

有了计算图,并设置好变量,调用convert_variables_to_constants生成需要的模型。

示例把滑动平均的影子变量赋给变量用的是“显示”指定,随着层数变多,这种写法很容易导致遗漏哪变量,怎么能优化它?TensorFlow提供了加载时重命名变量机制,这种机制的一个用途就是处理滑动平均。

variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY);
variables_to_restore = variable_averages.variables_to_restore();
saver = tf.train.Saver(variables_to_restore);

上面代码用变量重命名机制处理滑动平均,variables_to_restore存储着和滑动平圴相关、tf.train.Saver类所需的变量重命名字典。它要求用tf.train.Save生成saver,这就和保存模型中用加载计算图方式生成saver的方法相冲突,目前我没找到解决这冲突办法。

编辑于 2017-11-15

文章被以下专栏收录

    设计理念:1)同时支持开发传统app(包括游戏和非游戏)、机器人app。2)不用高深语法,提倡“C加class”。3)提供清晰的开发包目录结构,简化升级、维护以及同时开发多个app。4)一个编程方向。5)高度模块化,能无缝融合开源社区中项目。6)一种C/C++和脚本语言互补的解决框架。