TensorFlow2.x 模型部署

模型训练完后,往往需要将模型应用到生产环境中。最常见的就是通过TensorFlow Serving来将模型部署到服务器端,以便客户端进行访问。

TensorFlow Serving 安装

TensorFlow Serving一般安装在服务器端,最为方便,推荐在生产环境中 使用 Docker 部署 TensorFlow Serving 。当然也可以通过apt-get 安装 。这里我主要,使用的前者。

首先安装docker

然后,拉取最新的Tensorflow Serving镜像。

1
docker pull tensorflow/serving

模型部署

Keras Sequential 模式模型的部署(单输入,单输出)

模型构建

由于Keras Sequential 模式的输入输出形式比较固定单一,所以这里简单的构造一个Sequential 模型。

1
model = tf.keras.Sequential([tf.keras.layers.Dense(1)])

模型导出

模型构造好后,开始进行模型的导出。
由于这里只是示例,不进行数据输入训练等操作,通过TF2.0 中eager的特性来初始化模型。

1
2
3
4
5
6
version = '1'  #版本号
model_name = 'sequential_model'
saved_path = os.path.join('models',model_name)
data = tf.ones((2, 10))
model(datas
model.save(os.path.join(saved_path,version)) # models/sequential_model/1

注意:tensorflow Serving支持热更新,每次默认选取版本version最大的进行部署。因此,我们在保存模型的路径上需要加上指定的version

保存后的文件目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
└── sequential_model
├── 1
│   ├── assets
│   ├── saved_model.pb
│   └── variables
│   ├── variables.data-00000-of-00001
│   └── variables.index
└── 2
├── assets
├── saved_model.pb
└── variables
├── variables.data-00000-of-00001
└── variables.index

接着在终端中,通过以下命令,查看保存的模型结构

1
saved_model_cli show --dir models/sequential_model/1 --all

终端输出 重点关注下面信息:

1
2
3
4
5
6
7
8
9
10
11
12
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['dense_input'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_dense_input:0
The given SavedModel SignatureDef contains the following output(s):
outputs['dense'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 1)
name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

其中
inputs[‘dense_input’] 中的 dense_input 是请求 TensorFlow Serving服务时所需的 输入名
outputs[‘dense’] 中的dense是TensorFlow Serving服务 返回的输出名 ( 当outputs只有一个时默认 输出名为 outputs)

docker部署

接下来我们运行Docker中的tensorflow Serving 进行部署:

1
2
3
docker run -t --name sequential_model -p 8501:8501 \
--mount type=bind,source=/root/models,target=/models \
-e MODEL_NAME=sequential_model tensorflow/serving &

解释:
–name 定义容器的名字
-p 8051:8051 指的是 本地端口:容器内部端口 的映射。(注:tensorflow Serving 默认开启的是8501端口,如需修改则需进入容器中手动指定 –rest_api_port)
–mount type=bind, source=/root/models ,target=/models 指的是将本地/root/models 目录 映射到 docker容器中/models目录
-e MODEL_NAME=sequential_model 这里指的环境名称MODEL_NAME,tensorflow Serving会自动索引容器中/models/MODEL_NAME 目录下的模型文件

上面的docker 命令 相当于在容器中 执行下面命令。

1
2
3
4
tensorflow_model_server \
--rest_api_port=8501 \
--model_name=sequential_model \
--model_base_path= /models/sequential_model &

因此,也可以仅启动容器,设置端口映射,目录映射后 ,进入容器 通过自己手动启动tensorflow_model_server 来进行更多的自定义。

请求服务

终端中可以通过curl进行请求

1
curl -d '{"inputs": {"dense_input":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]]}}' -X POST http://localhost:8501/v1/models/sequential_model:predict

这里的dense_input 就是前面saved_model_cli在显示的输入名。

返回
由于只是单输出,所以这里隐藏了输出名,默认为outputs, 与saved_model_cli中输出名有点区别。

1
2
3
4
5
6
7
{
"outputs": [
[
0.736189902
]
]
}

Keras Model函数式模型的部署(多输入,多输出)

由于Sequential 模式模型的输入输出过于单一,在模型构建方面有天生的弱势。
这里介绍下Keras Model函数式模型多输入多输出 的部署。

模型构建

重点注意下,定义的name 属性。

1
2
3
4
5
6
7
import tensorflow as tf

input_1 = tf.keras.layers.Input(shape=(10,), dtype=tf.float32, name='a')
input_2 = tf.keras.layers.Input(shape=(10,), dtype=tf.float32, name='b')
output_1 = tf.keras.layers.Dense(1, name='dense_1')(input_1 + input_2)
output_2 = tf.keras.layers.Dense(1, name='dense_2')(input_1 - input_2)
model = tf.keras.Model(inputs=[input_1, input_2], outputs=[output_1, output_2])

模型导出

这里依然通过eager的特性进行模型的初始构建。

1
2
3
4
5
6
7
version = '1'  #版本号
model_name = 'keras_functional_model'
saved_path = os.path.join('models',model_name)
data1= tf.ones((2,10),dtype=tf.float32)
data2= tf.ones((2,10),dtype=tf.float32)
model((data1,data2)) #model({'a':data1,'b':data2}) 两种方法都可以
model.save(os.path.join(saved_path,version)) # models/keras_functional_model/1

接下来我们查看下保存模型的内部结构

1
saved_model_cli show --dir models/keras_functional_model/1 --all
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['a'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_a:0
inputs['b'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_b:0
The given SavedModel SignatureDef contains the following output(s):
outputs['dense_1'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 1)
name: StatefulPartitionedCall:0
outputs['dense_2'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 1)
name: StatefulPartitionedCall:1
Method name is: tensorflow/serving/predict

前面keras Sequential 模式中,输入名输出名,是系统根据 变量名自动生成的。
在Keras Model函数式模型 中 我们可以通过定义name属性来设置输入名输出名

docker部署

由于前面sequential_model 占用了本地8501端口,这里使用8502端口作为本地端口映射,tensorflow serving默认开启8501 所以内部端口8501 不用修改。

1
2
3
docker run -t --name keras_functional_model -p 8502:8501 \
--mount type=bind,source=/root/models,target=/models \
-e MODEL_NAME=keras_functional_model tensorflow/serving &

请求服务

终端中可以通过curl进行请求

1
curl -d '{"inputs": {"a":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]],"b":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]]}}' -X POST http://localhost:8502/v1/models/keras_functional_model:predict

返回

由于这里是多输出,返回的同时会加上输出名。与Keras Sequential模式有区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"outputs": {
"dense_1": [
[
0.251481533
]
],
"dense_2": [
[
0.0
]
]
}
}%

自定义 Keras 模型的部署 (多输入+mode,多输出)

如果Keras Sequential 模式 和 Keras Model函数式 模式 无法满足复杂模型需求怎么办?别担心,最大杀器自定义 Keras 模式可以帮你解决一切。

设想下,我们如果需要通过 mode,来控制模型的运行流程,这样的模型我们该怎么导出部署呢?
当mode=’train’ 时,输出a。
当mode=’predict’时,输出b。

方法一

模型构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import tensorflow as tf

class MyModel(tf.keras.Model):
def __init__(self, **kwargs):
super(MyModel, self).__init__(**kwargs)
self.dense_1 = tf.keras.layers.Dense(10)
self.dense_2 = tf.keras.layers.Dense(1)
@tf.function
def call(self, inputs):
a, b, mode = inputs
hidden = self.dense_1(a + b)
logits = self.dense_2(hidden)
if mode == 'predict':
return hidden, mode
return logits, mode

模型导出

这里依然通过eager的特性进行模型的初始构建。

1
2
3
4
5
6
7
8
9
10
version = '1'  #版本号
model_name = 'custom_model'
saved_path = os.path.join('models',model_name)
model = MyModel()
a = tf.ones((2, 10))
b = tf.ones((2, 10))
mode = 'train'
mode = tf.cast(mode, tf.string) #这里的strin 必须转换成tf.string类型。
print(model((a, b, mode)))
model.save(os.path.join(saved_path,version))

这里由于未显示的指定,输入的形状、类型、名字等。模型根据输入的数据进行自动推断的。因此输入的数据 必须是 tf的dtype类型。所以,在输入字符串时需要转换成tf.string
虽然,在我们平时训练模型时没有什么问题,但在导出模型时,会出现

1
TypeError: Invalid input_signature ; input_signature must be a possibly nested sequence of TensorSpec objects.

通过saved_model_cli对模型进行查看。

1
saved_model_cli show --dir models/keras_functional_model/1 --all

由于,并没有指定输入的名字,这里都由系统根据输入顺序,输出顺序自动命名,输入就是input_n,输出就是output_n.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['input_1'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_input_1:0
inputs['input_2'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_input_2:0
inputs['input_3'] tensor_info:
dtype: DT_STRING
shape: ()
name: serving_default_input_3:0
The given SavedModel SignatureDef contains the following output(s):
outputs['output_1'] tensor_info:
dtype: DT_FLOAT
shape: (-1, -1)
name: StatefulPartitionedCall:0
outputs['output_2'] tensor_info:
dtype: DT_STRING
shape: ()
name: StatefulPartitionedCall:1
Method name is: tensorflow/serving/predict

下面介绍下比较规范的自定义 Keras 模型 导出

方法二

模型构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import tensorflow as tf

class MyModel(tf.keras.Model):
def __init__(self, **kwargs):
super(MyModel, self).__init__(**kwargs)
self.dense_1 = tf.keras.layers.Dense(10)
self.dense_2 = tf.keras.layers.Dense(1)

@tf.function(input_signature=[(tf.TensorSpec([None, 10], name='a', dtype=tf.float32),
tf.TensorSpec([None, 10], name='b', dtype=tf.float32),
tf.TensorSpec([], name='mode', dtype=tf.string))])
def call(self, inputs):
a, b, mode = inputs
hidden = self.dense_1(a + b)
logits = self.dense_2(hidden)
if mode == 'predict':
return hidden, mode
return logits, mode

方法一 不同的是,我们通过显示的定义了inputs 需要输入的类型,形状,类别,名字。这样一来,模型就知道我们要输入的是什么了。

注意:在构建类似上面面多分支输出模型时,需要保持各分支输出的 变量类型,变量数量 一致,否则模型导出会抛出异常。

如:上面分支模型,当mode==’train’ 和 mode==’predict’时,return返回的都是tf.float32tf.string类型,且都是两个变量。若出现变量类型,变量数量不一致,则会提示TypeError

1
>TypeError: 'retval_' must have the same nested structure in the main and else branches:.....

注意:看到这里,细心的可能会发现,我在编写call()函数时,不管是单输入,还是多输入,我都是通过解包的方式进行输入,并没有改动 inputs 这个变量。我们在训练模型过程中,经常会直接显示的指明变量,比如 上面的call可以进行改写
def call(self,inputs): —-> def call(self, a,b,mode):
虽然,在train的过程中不会出错,但在部署导出模型时,会发生未知错误。
因此,推荐解包的方式进行输入变量。

模型导出

1
2
3
4
5
6
7
8
9
version = '2'  #版本号
model_name = 'custom_model'
saved_path = os.path.join('models',model_name)
model = MyModel()
a = tf.ones((2, 10))
b = tf.ones((2, 10))
mode = 'train'
print(model((a, b, mode)))
model.save(os.path.join(saved_path,version))

这里,我们也无需将mode手动转换成tf.string了,一切都由模型自动完成。
再通过saved_model_cli来查看下模型信息。

1
saved_model_cli show --dir models/keras_functional_model/2 --all

由于我们在tf.function中指定了,输入名,因此这里输入名都发生了改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['a'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_a:0
inputs['b'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_b:0
inputs['mode'] tensor_info:
dtype: DT_STRING
shape: ()
name: serving_default_mode:0
The given SavedModel SignatureDef contains the following output(s):
outputs['output_1'] tensor_info:
dtype: DT_FLOAT
shape: (-1, -1)
name: StatefulPartitionedCall:0
outputs['output_2'] tensor_info:
dtype: DT_STRING
shape: ()
name: StatefulPartitionedCall:1
Method name is: tensorflow/serving/predict

那么,问题来了,输入进行了自定义修改,输出呢? 别急,请看下面的最终版

终版(官方推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import tensorflow as tf
import os
class MyModel(tf.keras.Model):
def __init__(self, **kwargs):
super(MyModel, self).__init__(**kwargs)
self.dense_1 = tf.keras.layers.Dense(10)
self.dense_2 = tf.keras.layers.Dense(1)
self.dropout = tf.keras.layers.Dropout(0.1)

def call(self, inputs, training=None):
a, b = inputs
hidden = self.dense_1(a + b)
hidden = self.dropout(hidden, training=training)
logits = self.dense_2(hidden)
return logits

@tf.function(input_signature=[(tf.TensorSpec([None, 10], name='a', dtype=tf.float32),
tf.TensorSpec([None, 10], name='b', dtype=tf.float32))])
def sever(self, inputs):
return {"score": self.call(inputs, training=False)}

if __name__ == '__main__':
model = MyModel()
data = tf.ones((2, 10))
out = model((data, data), training=True)
print(out)
model.save("SavedModel", signatures={"serving_default": model.sever})

方法三 与 方法二 相比在输入的时候call方法中多了training=None,但默认导出时tf2会自动屏蔽掉training参数。这时我们可以对call方法进行重新包装,这里自定义了sever函数对call进行包装并对server指定 input_signature ,同时在输出时通过dic 指定输出变量名。最后 ,在model.save导出模型时, 指定对serving_default 签名对应的 调用函数进行指定即可。

最后通过saved_model_cli show –all –dir SavedModel命令对保存的模型进行查看,可以发现这次不仅自定义了输入而且还自定义了输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
signature_def['serving_default']:
The given SavedModel SignatureDef contains the following input(s):
inputs['a'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_a:0
inputs['b'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 10)
name: serving_default_b:0
The given SavedModel SignatureDef contains the following output(s):
outputs['score'] tensor_info:
dtype: DT_FLOAT
shape: (-1, 1)
name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

终于不用再看到input_n, output_n 而头疼了。

docker部署

由于前面8501 8502 端口被占用,这里我们启用8503端口进行映射。

1
2
3
docker run -t --name custom_model -p 8503:8501 \
--mount type=bind,source=/root/models,target=/models \
-e MODEL_NAME=custom_model tensorflow/serving &

通过返回的信息,我们可以看见,模型成功的加载了 version=’2’ 的模型文件。tensorflow serving 的热部署默认加载最大的版本号模型。

1
2
3
4
5
6
7
8
9
10
2020-08-23 14:13:13.477881: I external/org_tensorflow/tensorflow/cc/saved_model/loader.cc:199] Restoring SavedModel bundle.
2020-08-23 14:13:13.491605: I external/org_tensorflow/tensorflow/cc/saved_model/loader.cc:183] Running initialization op on SavedModel bundle at path: /models/custom_model/2
2020-08-23 14:13:13.495698: I external/org_tensorflow/tensorflow/cc/saved_model/loader.cc:303] SavedModel load for tags { serve }; Status: success: OK. Took 41678 microseconds.
2020-08-23 14:13:13.496311: I tensorflow_serving/servables/tensorflow/saved_model_warmup_util.cc:59] No warmup data file found at /models/custom_model/2/assets.extra/tf_serving_warmup_requests
2020-08-23 14:13:13.496841: I tensorflow_serving/core/loader_harness.cc:87] Successfully loaded servable version {name: custom_model version: 2}
2020-08-23 14:13:13.498519: I tensorflow_serving/model_servers/server.cc:367] Running gRPC ModelServer at 0.0.0.0:8500 ...
[warn] getaddrinfo: address family for nodename not supported
2020-08-23 14:13:13.499738: I tensorflow_serving/model_servers/server.cc:387] Exporting HTTP/REST API at:localhost:8501 ...
[evhttp_server.cc : 238] NET_LOG: Entering the event loop ...

请求服务

train

1
curl -d '{"inputs": {"a":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]],"b":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]],"mode":"train"}}' -X POST http://localhost:8503/v1/models/custom_model:predict

返回

1
2
3
4
5
6
7
8
9
10
{
"outputs": {
"output_1": [
[
2.90094423
]
],
"output_2": "train"
}
}

predict

1
curl -d '{"inputs": {"a":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]],"b":[[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]],"mode":"predict"}}' -X POST http://localhost:8503/v1/models/custom_model:predict

返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"outputs": {
"output_1": [
[
0.699072063,
0.756825566,
1.35330391,
-2.21226835,
1.43654501,
1.50765,
-1.43798947,
-0.434436381,
-0.289675713,
2.53842211
]
],
"output_2": "predict"
}
}

客户端中程序调用

这里以上文custom_model v2 为例子。

准备数据

1
2
3
4
5
6
7
8
import numpy as np
import json

a = np.ones((1, 10))
b = np.ones((1, 10))
mode = "train"
data = {"inputs": {"a": a.tolist(), "b": b.tolist(), "mode": mode}}
data = json.dumps(data)

pycurl

1
2
3
4
5
6
7
8
9
10
import pycurl
url = "http://localhost:8503/v1/models/custom_model:predict"
c = pycurl.Curl()
c.setopt(c.URL, url)
c.setopt(c.POSTFIELDS, data)
rs = c.performb_rs()
rs = eval(rs)
outputs = rs['outputs']
print(outputs)
c.close()

request

1
2
3
4
5
import requests
url = "http://localhost:8503/v1/models/custom_model:predict"
rs = requests.post(url,data)
outputs = rs.json()['outputs']
print(outputs)

输出:

1
{'output_1': [[2.90094423]], 'output_2': 'train'}