代码示例 / Keras 快速指南 / 使用 TFServing 提供 TensorFlow 模型服务

使用 TFServing 提供 TensorFlow 模型服务

作者: Dimitre Oliveira
创建日期 2023/01/02
上次修改日期 2023/01/02
描述: 如何使用 TensorFlow Serving 提供 TensorFlow 模型服务。

ⓘ 此示例使用 Keras 3

在 Colab 中查看 GitHub 源码


简介

构建机器学习模型后,下一步是提供服务。您可以通过将模型作为端点服务公开来实现。可以使用许多框架来完成此操作,但 TensorFlow 生态系统有自己的解决方案,称为 TensorFlow Serving

来自 TensorFlow Serving 的 GitHub 页面

TensorFlow Serving 是一款灵活、高性能的机器学习模型服务系统,专为生产环境而设计。它处理机器学习的推理方面,在训练后获取模型并管理其生命周期,通过高性能的、引用计数的查找表为客户端提供版本化的访问。TensorFlow Serving 提供了与 TensorFlow 模型的开箱即用集成,但可以轻松扩展以服务其他类型的模型和数据。"

注意一些特性

  • 它可以同时提供多个模型或同一模型的多个版本的服务
  • 它同时公开 gRPC 和 HTTP 推理端点
  • 它允许部署新模型版本而无需更改任何客户端代码
  • 它支持对新版本进行金丝雀发布以及对实验性模型进行 A/B 测试
  • 由于高效、低开销的实现,它对推理时间的延迟影响最小
  • 它具有一个调度程序,该调度程序将单个推理请求分组到批次中,以便在 GPU 上联合执行,并具有可配置的延迟控制
  • 它支持许多可服务对象:Tensorflow 模型、嵌入、词汇表、特征转换,甚至非基于 TensorFlow 的机器学习模型

本指南使用 Keras 应用 API 创建了一个简单的 MobileNet 模型,然后使用 TensorFlow Serving 提供服务。重点在于 TensorFlow Serving,而不是 TensorFlow 中的建模和训练。

注意:您可以在 此链接 找到包含完整工作代码的 Colab 笔记本。


依赖项

import os

os.environ["KERAS_BACKEND"] = "tensorflow"

import json
import shutil
import requests
import numpy as np
import tensorflow as tf
import keras
import matplotlib.pyplot as plt

模型

在这里,我们从 Keras 应用 加载预训练的 MobileNet,这是我们将要提供的服务模型。

model = keras.applications.MobileNet()
Downloading data from https://storage.googleapis.com/tensorflow/keras-applications/mobilenet/mobilenet_1_0_224_tf.h5
 17225924/17225924 ━━━━━━━━━━━━━━━━━━━━ 0s 0us/step

预处理

大多数模型无法直接在原始数据上运行,它们通常需要某种预处理步骤来调整数据以满足模型要求,在本例中,从 MobileNet 的 API 页面 可以看出,其输入图像需要三个基本步骤

  • 像素值归一化到 [0, 1] 范围
  • 像素值缩放至 [-1, 1] 范围
  • 形状为 (224, 224, 3) 的图像,表示 (高度,宽度,通道)

我们可以使用以下函数完成所有这些操作

def preprocess(image, mean=0.5, std=0.5, shape=(224, 224)):
    """Scale, normalize and resizes images."""
    image = image / 255.0  # Scale
    image = (image - mean) / std  # Normalize
    image = tf.image.resize(image, shape)  # Resize
    return image

关于使用“keras.applications”API 进行预处理和后处理的说明

Keras 应用 API 中可用的所有模型也提供 preprocess_inputdecode_predictions 函数,这些函数分别负责每个模型的预处理和后处理,并且已经包含了这些步骤所需的所有逻辑。这是使用 Keras 应用模型处理输入和输出的推荐方法。在本指南中,我们没有使用它们,以便更清晰地展示自定义签名的优势。


后处理

在同一上下文中,大多数模型输出的值需要额外的处理才能满足用户需求,例如,用户不想知道给定图像每个类的 logits 值,用户想要知道它属于哪个类。对于我们的模型,这意味着在模型输出之上进行以下转换

  • 获取预测值最高的类的索引
  • 从该索引获取类的名称
# Download human-readable labels for ImageNet.
imagenet_labels_url = (
    "https://storage.googleapis.com/download.tensorflow.org/data/ImageNetLabels.txt"
)
response = requests.get(imagenet_labels_url)
# Skipping background class
labels = [x for x in response.text.split("\n") if x != ""][1:]
# Convert the labels to the TensorFlow data format
tf_labels = tf.constant(labels, dtype=tf.string)


def postprocess(prediction, labels=tf_labels):
    """Convert from probs to labels."""
    indices = tf.argmax(prediction, axis=-1)  # Index with highest prediction
    label = tf.gather(params=labels, indices=indices)  # Class name
    return label

现在让我们下载一张香蕉图片,看看所有内容是如何组合在一起的。

response = requests.get("https://i.imgur.com/j9xCCzn.jpeg", stream=True)

with open("banana.jpeg", "wb") as f:
    shutil.copyfileobj(response.raw, f)

sample_img = plt.imread("./banana.jpeg")
print(f"Original image shape: {sample_img.shape}")
print(f"Original image pixel range: ({sample_img.min()}, {sample_img.max()})")
plt.imshow(sample_img)
plt.show()

preprocess_img = preprocess(sample_img)
print(f"Preprocessed image shape: {preprocess_img.shape}")
print(
    f"Preprocessed image pixel range: ({preprocess_img.numpy().min()},",
    f"{preprocess_img.numpy().max()})",
)

batched_img = tf.expand_dims(preprocess_img, axis=0)
batched_img = tf.cast(batched_img, tf.float32)
print(f"Batched image shape: {batched_img.shape}")

model_outputs = model(batched_img)
print(f"Model output shape: {model_outputs.shape}")
print(f"Predicted class: {postprocess(model_outputs)}")
Original image shape: (540, 960, 3)
Original image pixel range: (0, 255)

png

Preprocessed image shape: (224, 224, 3)
Preprocessed image pixel range: (-1.0, 1.0)
Batched image shape: (1, 224, 224, 3)
Model output shape: (1, 1000)
Predicted class: [b'banana']

保存模型

要将我们训练好的模型加载到 TensorFlow Serving 中,我们首先需要将其保存为 SavedModel 格式。这将在定义良好的目录层次结构中创建一个 protobuf 文件,并将包含版本号。TensorFlow Serving 允许我们选择在进行推理请求时要使用哪个版本的模型或“可服务对象”。每个版本都将导出到给定路径下的不同子目录中。

model_dir = "./model"
model_version = 1
model_export_path = f"{model_dir}/{model_version}"

tf.saved_model.save(
    model,
    export_dir=model_export_path,
)

print(f"SavedModel files: {os.listdir(model_export_path)}")
INFO:tensorflow:Assets written to: ./model/1/assets

INFO:tensorflow:Assets written to: ./model/1/assets

SavedModel files: ['variables', 'saved_model.pb', 'assets', 'fingerprint.pb']

检查您的保存模型

我们将使用命令行实用程序 saved_model_cli 来查看 MetaGraphDefs(模型)和 SignatureDefs(您可以调用的方法)在我们的 SavedModel 中。请参阅 TensorFlow 指南中 关于 SavedModel CLI 的讨论

!saved_model_cli show --dir {model_export_path} --tag_set serve --signature_def serving_default
The given SavedModel SignatureDef contains the following input(s):
  inputs['inputs'] tensor_info:
      dtype: DT_FLOAT
      shape: (-1, 224, 224, 3)
      name: serving_default_inputs:0
The given SavedModel SignatureDef contains the following output(s):
  outputs['output_0'] tensor_info:
      dtype: DT_FLOAT
      shape: (-1, 1000)
      name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

这告诉我们很多关于模型的信息!例如,我们可以看到它的输入具有 4D 形状 (-1, 224, 224, 3),这意味着 (批次大小,高度,宽度,通道),还要注意,此模型需要特定的图像形状 (224, 224, 3),这意味着我们可能需要在将图像发送到模型之前重新调整其形状。我们还可以看到模型的输出具有 (-1, 1000) 形状,它们是 ImageNet 数据集中 1000 个类的 logits。

这些信息并不能告诉我们所有内容,例如像素值需要在 [-1, 1] 范围内,但这是一个良好的开端。


使用 TensorFlow Serving 提供模型服务

安装 TFServing

我们准备使用 Aptitude 安装 TensorFlow Serving,因为此 Colab 在 Debian 环境中运行。我们将 tensorflow-model-server 包添加到 Aptitude 知道的包列表中。请注意,我们正在以 root 身份运行。

注意:此示例以原生方式运行 TensorFlow Serving,但 您也可以在 Docker 容器中运行它,这是开始使用 TensorFlow Serving 的最简单方法之一。

wget 'http://storage.googleapis.com/tensorflow-serving-apt/pool/tensorflow-model-server-universal-2.8.0/t/tensorflow-model-server-universal/tensorflow-model-server-universal_2.8.0_all.deb'
dpkg -i tensorflow-model-server-universal_2.8.0_all.deb

开始运行 TensorFlow Serving

在这里,我们开始运行 TensorFlow Serving 并加载我们的模型。加载后,我们可以开始使用 REST 发出推理请求。有一些重要的参数

  • port:您将用于 gRPC 请求的端口。
  • rest_api_port:您将用于 REST 请求的端口。
  • model_name:您将在 REST 请求的 URL 中使用此参数。它可以是任何内容。
  • model_base_path:这是保存模型的目录的路径。

查看 TFServing API 参考 以获取所有可用的参数。

# Environment variable with the path to the model
os.environ["MODEL_DIR"] = f"{model_dir}"
%%bash --bg
nohup tensorflow_model_server \
  --port=8500 \
  --rest_api_port=8501 \
  --model_name=model \
  --model_base_path=$MODEL_DIR >server.log 2>&1
# We can check the logs to the server to help troubleshooting
!cat server.log

输出

[warn] getaddrinfo: address family for nodename not supported
[evhttp_server.cc : 245] NET_LOG: Entering the event loop ...
# Now we can check if tensorflow is in the active services
!sudo lsof -i -P -n | grep LISTEN

输出

node         7 root   21u  IPv6  19100      0t0  TCP *:8080 (LISTEN)
kernel_ma   34 root    7u  IPv4  18874      0t0  TCP 172.28.0.12:6000 (LISTEN)
colab-fil   63 root    5u  IPv4  17975      0t0  TCP *:3453 (LISTEN)
colab-fil   63 root    6u  IPv6  17976      0t0  TCP *:3453 (LISTEN)
jupyter-n   81 root    6u  IPv4  18092      0t0  TCP 172.28.0.12:9000 (LISTEN)
python3    101 root   23u  IPv4  18252      0t0  TCP 127.0.0.1:44915 (LISTEN)
python3    132 root    3u  IPv4  20548      0t0  TCP 127.0.0.1:15264 (LISTEN)
python3    132 root    4u  IPv4  20549      0t0  TCP 127.0.0.1:37977 (LISTEN)
python3    132 root    9u  IPv4  20662      0t0  TCP 127.0.0.1:40689 (LISTEN)
tensorflo 1101 root    5u  IPv4  35543      0t0  TCP *:8500 (LISTEN)
tensorflo 1101 root   12u  IPv4  35548      0t0  TCP *:8501 (LISTEN)

向 TensorFlow Serving 中的模型发出请求

现在让我们为推理请求创建一个 JSON 对象,看看我们的模型对其进行分类的效果如何

REST API

可服务对象的最新版本

我们将发送一个预测请求,作为 POST 请求到我们服务器的 REST 端点,并以示例形式传递。我们将请求我们的服务器提供我们可服务模型的最新版本,而不指定特定的版本。

data = json.dumps(
    {
        "signature_name": "serving_default",
        "instances": batched_img.numpy().tolist(),
    }
)
url = "https://127.0.0.1:8501/v1/models/model:predict"


def predict_rest(json_data, url):
    json_response = requests.post(url, data=json_data)
    response = json.loads(json_response.text)
    rest_outputs = np.array(response["predictions"])
    return rest_outputs
rest_outputs = predict_rest(data, url)

print(f"REST output shape: {rest_outputs.shape}")
print(f"Predicted class: {postprocess(rest_outputs)}")

输出

REST output shape: (1, 1000)
Predicted class: [b'banana']

gRPC API

gRPC 基于远程过程调用 (RPC) 模型,是一种用于实现 RPC API 的技术,它使用 HTTP 2.0 作为其底层传输协议。gRPC 通常适用于低延迟、高可扩展性和分布式系统。如果您想了解更多关于 REST 与 gRPC 的权衡,请查看这篇文章

import grpc

# Create a channel that will be connected to the gRPC port of the container
channel = grpc.insecure_channel("localhost:8500")
pip install -q tensorflow_serving_api
from tensorflow_serving.apis import predict_pb2, prediction_service_pb2_grpc

# Create a stub made for prediction
# This stub will be used to send the gRPCrequest to the TF Server
stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
# Get the serving_input key
loaded_model = tf.saved_model.load(model_export_path)
input_name = list(
    loaded_model.signatures["serving_default"].structured_input_signature[1].keys()
)[0]
def predict_grpc(data, input_name, stub):
    # Create a gRPC request made for prediction
    request = predict_pb2.PredictRequest()

    # Set the name of the model, for this use case it is "model"
    request.model_spec.name = "model"

    # Set which signature is used to format the gRPC query
    # here the default one "serving_default"
    request.model_spec.signature_name = "serving_default"

    # Set the input as the data
    # tf.make_tensor_proto turns a TensorFlow tensor into a Protobuf tensor
    request.inputs[input_name].CopyFrom(tf.make_tensor_proto(data.numpy().tolist()))

    # Send the gRPC request to the TF Server
    result = stub.Predict(request)
    return result


grpc_outputs = predict_grpc(batched_img, input_name, stub)
grpc_outputs = np.array([grpc_outputs.outputs['predictions'].float_val])

print(f"gRPC output shape: {grpc_outputs.shape}")
print(f"Predicted class: {postprocess(grpc_outputs)}")

输出

gRPC output shape: (1, 1000)
Predicted class: [b'banana']

自定义签名

请注意,对于此模型,我们始终需要预处理和后处理所有样本以获得所需的输出,如果维护和服务由大型团队开发的多个模型,并且每个模型可能需要不同的处理逻辑,这可能会变得非常棘手。

TensorFlow 允许我们自定义模型图以嵌入所有这些处理逻辑,这使得模型服务变得更容易,有多种方法可以实现这一点,但由于我们将使用 TFServing 来服务模型,因此我们可以直接在服务签名中自定义模型图。

我们可以使用以下代码导出包含预处理和后处理逻辑作为默认签名的相同模型,这允许此模型对原始数据进行预测。

def export_model(model, labels):
    @tf.function(input_signature=[tf.TensorSpec([None, None, None, 3], tf.float32)])
    def serving_fn(image):
        processed_img = preprocess(image)
        probs = model(processed_img)
        label = postprocess(probs)
        return {"label": label}

    return serving_fn


model_sig_version = 2
model_sig_export_path = f"{model_dir}/{model_sig_version}"

tf.saved_model.save(
    model,
    export_dir=model_sig_export_path,
    signatures={"serving_default": export_model(model, labels)},
)
!saved_model_cli show --dir {model_sig_export_path} --tag_set serve --signature_def serving_default
INFO:tensorflow:Assets written to: ./model/2/assets

INFO:tensorflow:Assets written to: ./model/2/assets

The given SavedModel SignatureDef contains the following input(s):
  inputs['image'] tensor_info:
      dtype: DT_FLOAT
      shape: (-1, -1, -1, 3)
      name: serving_default_image:0
The given SavedModel SignatureDef contains the following output(s):
  outputs['label'] tensor_info:
      dtype: DT_STRING
      shape: (-1)
      name: StatefulPartitionedCall:0
Method name is: tensorflow/serving/predict

请注意,此模型具有不同的签名,其输入仍然是 4D,但现在形状为 (-1, -1, -1, 3),这意味着它支持任何高度和宽度大小的图像。其输出也具有不同的形状,它不再输出 1000 长度的 logits。

我们可以使用以下 API 测试模型使用特定签名的预测

batched_raw_img = tf.expand_dims(sample_img, axis=0)
batched_raw_img = tf.cast(batched_raw_img, tf.float32)

loaded_model = tf.saved_model.load(model_sig_export_path)
loaded_model.signatures["serving_default"](**{"image": batched_raw_img})
{'label': <tf.Tensor: shape=(1,), dtype=string, numpy=array([b'banana'], dtype=object)>}

使用可服务模型的特定版本进行预测

现在让我们指定可服务模型的特定版本。请注意,当我们使用自定义签名保存模型时,我们使用了不同的文件夹,第一个模型保存在文件夹 /1(版本 1)中,而具有自定义签名的模型保存在文件夹 /2(版本 2)中。默认情况下,TFServing 将服务所有共享相同基本父文件夹的模型。

REST API

data = json.dumps(
    {
        "signature_name": "serving_default",
        "instances": batched_raw_img.numpy().tolist(),
    }
)
url_sig = "https://127.0.0.1:8501/v1/models/model/versions/2:predict"
print(f"REST output shape: {rest_outputs.shape}")
print(f"Predicted class: {rest_outputs}")

输出

REST output shape: (1,)
Predicted class: ['banana']

gRPC API

channel = grpc.insecure_channel("localhost:8500")
stub = prediction_service_pb2_grpc.PredictionServiceStub(channel)
input_name = list(
    loaded_model.signatures["serving_default"].structured_input_signature[1].keys()
)[0]
grpc_outputs = predict_grpc(batched_raw_img, input_name, stub)
grpc_outputs = np.array([grpc_outputs.outputs['label'].string_val])

print(f"gRPC output shape: {grpc_outputs.shape}")
print(f"Predicted class: {grpc_outputs}")

输出

gRPC output shape: (1, 1)
Predicted class: [[b'banana']]

其他资源