代码示例 / 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']]

其他资源