面向機器智能的TensorFlow實踐:產品環境中模型的部署
在了解如何利用TesnsorFlow構建和訓練各種模型——從基本的機器學習模型到復雜的深度學習網絡后,我們就要考慮如何將訓練好的模型投入于產品,以使其能夠為其他應用所用,本文對此將進行詳細介紹。文章節選自《面向機器智能的TensorFlow實踐》第7章。
本文將創建一個簡單的Web App,使用戶能夠上傳一幅圖像,并對其運行Inception模型,實現圖像的自動分類。
搭建TensorFlow服務開發環境
Docker鏡像
TensorFlow服務是用于構建允許用戶在產品中使用我們提供的模型的服務器的工具。在開發過程中,使用該工具的方法有兩種:手工安裝所有的依賴項和工具,并從源碼開始構建;或利用Docker鏡像。這里準備使用后者,因為它更容易、更干凈,同時允許在其他不同于Linux的環境中進行開發。
如果不了解Docker鏡像,不妨將其想象為一個輕量級的虛擬機鏡像,但它在運行時不需要以在其中運行完整的操作系統為代價。如果尚未安裝Docker,請在開發機中安裝它,點擊查看具體安裝步驟(https://docs.docker.com/engine/installation/)。
為了使用Docker鏡像,還可利用筆者提供的文件(https://github.com/tensorflow/serving/blob/master/tensorflow_serving/tools/docker/Dockerfile.devel),它是一個用于在本地創建鏡像的配置文件。要使用該文件,可使用下列命令:
- docker build --pull -t $USER/tensorflow-serving-devel
- https://raw.githubusercontent.com/tensorflow/serving/master/
- tensorflow_serving/tools/docker/Dockerfile.devel
請注意,執行上述命令后,下載所有的依賴項可能需要一段較長的時間。
上述命令執行完畢后,為了使用該鏡像運行容器,可輸入下列命令:
- docker run -v $HOME:/mnt/home -p 9999:9999 -it $USER/
- tensorflow-serving-devel
該命令執行后會將你的home目錄加載到容器的/mnt/home路徑中,并允許在其中的一個終端下工作。這是非常有用的,因為你可使用自己偏好的IDE或編輯器直接編輯代碼,同時在運行構建工具時僅使用該容器。它還會開放端口9999,使你可從自己的主機中訪問它,并供以后將要構建的服務器使用。
鍵入exit命令可退出該容器終端,使其停止運行,也可利用上述命令在需要的時候啟動它。
Bazel工作區
由于TensorFlow服務程序是用C++編寫的,因此在構建時應使用Google的Bazel構建工具。我們將從最近創建的容器內部運行Bazel。
Bazel在代碼級管理著第三方依賴項,而且只要它們也需要用Bazel構建,Bazel便會自動下載和構建它們。為了定義我們的項目將支持哪些第三方依賴項,必須在項目庫的根目錄下定義一個WORKSPACE文件。
我們需要的依賴項是TensorFlow服務庫。在我們的例子中,TensorFlow模型庫包含了Inception模型的代碼。
不幸的是,在撰寫本書時,TensorFlow服務尚不支持作為Git庫通過Bazel直接引用,因此必須在項目中將它作為一個Git的子模塊包含進去:
- # 在本地機器上
- mkdir ~/serving_example
- cd ~/serving_example
- git init
- git submodule add https://github.com/tensorflow/serving.git
- tf_serving
- git.submodule update - -init - -recursive
下面利用WORKSPACE文件中的local_repository規則將第三方依賴項定義為在本地存儲的文件。此外,還需利用從項目中導入的tf_workspace規則對TensorFlow的依賴項初始化:
- # Bazel WORKSPACE文件
- workspace(name = "serving")
- local_repository(
- name = "tf_serving",
- path = _workspace_dir__ + "/tf_serving",
- local_repository(
- name = "org_tensorflow",
- path = _workspace_dir__ + "/tf_serving/tensorflow",
- )
- load('//tf_serving/tensorflow/tensorflow:workspace.bzl',
- 'tf_workspace')
- tf_workspace("tf_serving/tensorflow/", "@org_tensorflow")
- bind(
- name = "libssl",
- actual = "@boringssl_git//:ssl",
- )
- bind(
- name = "zlib",
- actual = "@zlib_archive//:zlib"
- )
- # 僅當導入inception 模型時需要
- local_repository(
- name = "inception_model",
- path = __workspace_dir__ + "/tf_serving/tf_models/
- inception”,
- )
- 最后,需要從容器內為Tensorflow運行./configure:
- # 在Docker容器中
- cd /mnt/home/serving_example/tf_serving/tensorflow
- ./configure
導出訓練好的模型
一旦模型訓練完畢并準備進行評估,便需要將數據流圖及其變量值導出,以使其可為產品所用。
模型的數據流圖應當與其訓練版本有所區分,因為它必須從占位符接收輸入,并對其進行單步推斷以計算輸出。對于Inception模型這個例子,以及對于任意一般圖像識別模型,我們希望輸入是一個表示了JPEG編碼的圖像字符串,這樣就可輕易地將它傳送到消費App中。這與從TFRecord文件讀取訓練輸入頗為不同。
定義輸入的一般形式如下:
- def convert_external_inputs (external_x):
- #將外部輸入變換為推斷所需的輸入格式
- def inference(x):
- #從原始模型中……
- external_x = tf.placeholder(tf.string)
- x = convert_external_inputs(external_x)
- y = inference(x)
在上述代碼中,為輸入定義了占位符,并調用了一個函數將用占位符表示的外部輸入轉換為原始推斷模型所需的輸入格式。例如,我們需要將JPEG字符串轉換為Inception模型所需的圖像格式。最后,調用原始模型推斷方法,依據轉換后的輸入得到推斷結果。
例如,對于Inception模型,應當有下列方法:
- import tensorflow as tf
- from tensorflow_serving.session_bundle import exporter
- from inception import inception_model
- def convert_external_inputs (external_x)
- # 將外部輸入變換為推斷所需的輸入格式
- # 將圖像字符串轉換為一個各分量位于[0,1]內的像素張量
- image =
- tf.image.convert_image_dtype(tf.image.decode_jpeg(external_x,
- channels=3), tf.float32)
- # 對圖像尺寸進行縮放,使其符合模型期望的寬度和高度
- images = tf.image.resize_bilinear(tf.expand_dims(image,
- 0),[299,299])
- # 將像素值變換到模型所要求的區間[-1,1]內
- images =tf.mul(tf.sub(image,0.5),2)
- return images
- def inference(images):
- logits, _ = inception_model.inference(images, 1001)
- return logits
這個推斷方法要求各參數都被賦值。我們將從一個訓練檢查點恢復這些參數值。你可能還記得,在前面的章節中,我們周期性地保存模型的訓練檢查點文件。那些文件中包含了當時學習到的參數,因此當出現異常時,訓練進展不會受到影響。
訓練結束時,最后一次保存的訓練檢查點文件中將包含最后更新的模型參數,這正是我們希望在產品中使用的版本。
要恢復檢查點文件,可使用下列代碼:
- saver = tf.train.Saver()
- with tf.Session() as sess:
- # 從訓練檢查點文件恢復各交量
- ckpt = tf.train.get_checkpoint_state(sys.argv[1])
- if ckpt and ckpt.model_checkpoint_path:
- saver.restore(sess, sys.argv[1])+”/”+
- ckpt.model_checkpoint_path)
- else:
- print(“Checkpoint file not found”)
- raise SystemExit
對于Inception模型,可從下列鏈接下載一個預訓練的檢查點文件:http://download.tensorflow.org/models/image/imagenet/inception-v3-2016-03-01.tar.gz。
- # 在docker容器中
- cd/tmp
- curl -O http://download.tensorflow.org/models/image/imagenet/
- inception-v3-2016-03-01.tar.gz
- tar –xzf inception-v3-2016-03-01.tar.gz
最后,利用tensorflow_serving.session_bundle.exporter.Exporter類將模型導出。我們通過傳入一個保存器實例創建了一個它的實例。然后,需要利用exporter.classification_signature方法創建該模型的簽名。該簽名指定了什么是input_tensor以及哪些是輸出張量。輸出由classes_tensor構成,它包含了輸出類名稱列表以及模型分配給各類別的分值(或概率)的socres_tensor。通常,在一個包含的類別數相當多的模型中,應當通過配置指定僅返回tf.nn.top_k所選擇的那些類別,即按模型分配的分數按降序排列后的前K個類別。
最后一步是應用這個調用了exporter.Exporter.init方法的簽名,并通過export方法導出模型,該方法接收一個輸出路徑、一個模型的版本號和會話對象。
- Scores, class_ids=tf.nn.top_k(y,NUM_CLASS_TO_RETURN)
- #為了簡便起見,我們將僅返回類別ID,應當另外對它們命名
- classes =
- tf.contrib.lookup.index_to_string(tf.to_int64(class_ids)
- mapping=tf.constant([str(i) for i in range(1001)]))
- model_exporter = exporter.Exporter(saver)
- signature = exporter.classification_signature(
- input_tensor=external_x, classes_tensor=classes,
- scores_tensor=scores)
- model_exporter.init(default_graph_signature=signature,
- init_op=tf.initialize_all_tables())
- model_exporter.export(sys.argv[1]+ "/export"
- tf.constant(time.time()), sess)
由于對Exporter類代碼中自動生成的代碼存在依賴,所以需要在Docker容器內部使用bazel運行我們的導出器。
為此,需要將代碼保存到之前啟動的bazel工作區內的exporter.py中。此外,還需要一個帶有構建規則的BUILD文件,類似于下列內容:
- # BUILD文件
- py_binary(
- name = "export",
- srcs =[
- “export.py”,
- ],
- deps = [
- “//tensorflow_serving/session_bundle:exporter”,
- “@org_tensorflow//tensorflow:tensorflow_py”,
- #僅在導出 inception模型時需
- “@inception_model//inception”,
- ],
- )
然后,可在容器中通過下列命令運行導出器:
- # 在Docker容器中
- cd /mnt/home/serving_example
它將依據可從/tmp/inception-v3中提取到的檢查點文件在/tmp/inception-v3/{current_timestamp}/ 中創建導出器。
注意,首次運行它時需要花費一些時間,因為它必須要對TensorFlow進行編譯。
定義服務器接口
接下來需要為導出的模型創建一個服務器。
TensorFlow服務使用gRPC協議(gRPC是一種基于HTTP/2的二進制協議)。它支持用于創建服務器和自動生成客戶端存根的各種語言。由于TensorFlow是基于C++的,所以需要在其中定義自己的服務器。幸運的是,服務器端代碼比較簡短。
為了使用gRPS,必須在一個protocol buffer中定義服務契約,它是用于gRPC的IDL(接口定義語言)和二進制編碼。下面來定義我們的服務。前面的導出一節曾提到,我們希望服務有一個能夠接收一個JPEG編碼的待分類的圖像字符串作為輸入,并可返回一個依據分數排列的由推斷得到的類別列表。
這樣的服務應定義在一個classification_service.proto文件中,類似于:
- syntax = "proto3";
- message ClassificationRequest {
- // JPEG 編碼的圖像字符串
- bytes input = 1;
- };
- message ClassificationResponse{
- repeated ClassificationClass classes = 1;
- };
- message ClassificationClass {
- string name = 1;
- float score = 2;
- }
可對能夠接收一幅圖像,或一個音頻片段或一段文字的任意類型的服務使用同一個接口。
為了使用像數據庫記錄這樣的結構化輸入,需要修改ClassificationRequest消息。例如,如果試圖為Iris數據集構建分類服務,則需要如下編碼:
- message ClassificationRequest {
- float petalWidth = 1;
- float petaHeight = 2;
- float petalWidth = 3;
- float petaHeight = 4;
- }
這個proto文件將由proto編譯器轉換為客戶端和服務器相應的類定義。為了使用protobuf編譯器,必須為BUILD文件添加一條新的規則,類似于:
- load("@protobuf//:protobuf.bzl", "cc_proto_library")
- cc_proto_library(
- name="classification_service_proto",
- srcs=["classification_service.proto"],
- cc_libs = ["@protobuf//:protobuf"],
- protoc="@protobuf//:protoc",
- default_runtime="@protobuf//:protobuf",
- use_grpc_plugin=1
- )
請注意位于上述代碼片段中最上方的load。它從外部導入的protobuf庫中導入了cc_proto_library規則定義。然后,利用它為proto文件定義了一個構建規則。利用bazel build :classification_service_proto可運行該構建,并通過bazel-genfiles/classification_service.grpc.pb.h檢查結果:
- …
- class ClassificationService {
- ...
- class Service : public ::grpc::Service {
- public:
- Service();
- virtual ~Service();
- virtual ::grpc::Status classify(::grpc::ServerContext*
- context, const ::ClassificationRequest*
- request, ::ClassificationResponse* response);
- };
按照推斷邏輯,ClassificationService::Service是必須要實現的接口。我們也可通過檢查bazel-genfiles/classification_service.pb.h查看request和response消息的定義:
- …
- class ClassificationRequest :
- public ::google::protobuf::Message {
- ...
- const ::std::string& input() const;
- void set_input(const ::std::string& value);
- ...
- }
- class ClassificationResponse :
- public ::google::protobuf::Message {
- ...
- const ::ClassificationClass& classes() const;
- void set_allocated_classes(::ClassificationClass*
- classes);
- ...
- }
- class ClassificationClass :
- public ::google::protobuf::Message {
- ...
- const ::std::string& name() const;
- void set_name(const ::std::string& value);
- float score() const;
- void set_score(float value);
- ...
- }
可以看到,proto定義現在變成了每種類型的C++類接口。它們的實現也是自動生成的,這樣便可直接使用它們。
實現推斷服務器
為實現ClassificationService::Service,需要加載導出模型并對其調用推斷方法。這可通過一個SessionBundle對象來實現,該對象是從導出的模型創建的,它包含了一個帶有完全加載的數據流圖的TF會話對象,以及帶有定義在導出工具上的分類簽名的元數據。
為了從導出的文件路徑創建SessionBundle對象,可定義一個便捷函數,以處理這個樣板文件:
- #include <iostream>
- #include <memory>
- #include <string>
- #include <grpc++/grpc++.h>
- #include "classification_service.grpc.pb.h"
- #include "tensorflow_serving/servables/tensorflow/
- session_bundle_factory.h"
- using namespace std;
- using namespace tensorflow::serving;
- using namespace grpc;
- unique_ptr<SessionBundle> createSessionBundle(const string&
- pathToExportFiles) {
- SessionBundleConfig session_bundle_config =
- SessionBundleConfig();
- unique_ptr<SessionBundleFactory> bundle_factory;
- SessionBundleFactory::Create(session_bundle_config,
- &bundle_factory);
- unique_ptr<SessionBundle> sessionBundle;
- bundle_factory-
- >CreateSessionBundle(pathToExportFiles, &sessionBundle);
- return sessionBundle;
- }
在這段代碼中,我們利用了一個SessionBundleFactory類創建了SessionBundle對象,并將其配置為從pathToExportFiles指定的路徑中加載導出的模型。最后返回一個指向所創建的SessionBundle實例的unique指針。
接下來需要定義服務的實現—ClassificationServiceImpl,該類將接收SessionBundle實例作為參數,以在推斷中使用:
- class ClassificationServiceImpl final : public
- ClassificationService::Service {
- private:
- unique_ptr<SessionBundle> sessionBundle;
- public:
- ClassificationServiceImpl(unique_ptr<SessionBundle>
- sessionBundle) :
- sificationServiceImpl(unique_ptr<Sessi
- Status classify(ServerContext* context, const
- ClassificationRequest* request,
- ClassificationResponse* response)
- override {
- // 加載分類簽名
- ClassificationSignature signature;
- const tensorflow::Status signatureStatus =
- GetClassificationSignature(sessionBundle-
- >meta_graph_def, &signature);
- if (!signatureStatus.ok()) {
- return Status(StatusCode::INTERNAL,
- signatureStatus.error_message());
- }
- // 將 protobuf 輸入變換為推斷輸入張量
- tensorflow::Tensor
- input(tensorflow::DT_STRING, tensorflow::TensorShape());
- input.scalar<string>()() = request->input();
- vector<tensorflow::Tensor> outputs;
- //運行推斷
- const tensorflow::Status inferenceStatus =
- sessionBundle->session->Run(
- {{signature.input().tensor_name(),
- input}},
- {signature.classes().tensor_name(),
- signature.scores().tensor_name()},
- {},
- &outputs);
- if (!inferenceStatus.ok()) {
- return Status(StatusCode::INTERNAL,
- inferenceStatus.error_message());
- }
- //將推斷輸出張量變換為protobuf輸出
- for (int i = 0; i <
- outputs[0].vec<string>().size(); ++i) {
- ClassificationClass
- *classificationClass = response->add_classes();
- classificationClass-
- >set_name(outputs[0].flat<string>()(i));
- classificationClass-
- >set_score(outputs[1].flat<float>()(i));
- }
- return Status::OK;
- }
- };
classify方法的實現包含了4個步驟:
- 利用GetClassificationSignature函數加載存儲在模型導出元數據中的Classification-Signature。這個簽名指定了輸入張量的(邏輯)名稱到所接收的圖像的真實名稱以及數據流圖中輸出張量的(邏輯)名稱到對其獲得推斷結果的映射。
- 將JPEG編碼的圖像字符串從request參數復制到將被進行推斷的張量。
- 運行推斷。它從sessionBundle獲得TF會話對象,并運行一次,同時傳入輸入和輸出張量的推斷。
- 從輸出張量將結果復制到由ClassificationResponse消息指定的形狀中的response輸出參數并格式化。
最后一段代碼是設置gRPC服務器并創建ClassificationServiceImpl實例(用Session-Bundle對象進行配置)的樣板代碼。
- int main(int argc, char** argv) {
- if (argc < 3) {
- cerr << "Usage: server <port> /path/to/export/files" <<
- endl;
- return 1;
- }
- const string serverAddress(string("0.0.0.0:") +
- argv[1]);
- const string pathToExportFile (argv[2]) ;
- unique_ptr<SessionBundle> sessionBundle =
- createSessionBundle(pathToExportFiles);
- const string serverAddres
- classificationServiceImpl(move(sessionBundle));
- ServerBuilder builder;
- builder. AddListeningPort(serverAddress,
- grpc::InsecureServerCredentials());
- builder.RegisterService(&classificationServiceImpl);
- unique_ptr<Server> server = builder.BuildAndStart();
- cout << "Server listening on " << serverAddress << endl;
- server->Wait();
- return 0;
- }
為了編譯這段代碼,需要在BUILD文件中為其定義一條規則:
- cc_binary(
- name = "server",
- srcs = [
- "server.cc",
- ],
- deps = [
- ":classification_service_proto",
- "@tf_serving//tensorflow_serving/servables/
- tensorflow:session_bundle_factory",
- "@grpc//:grpc++",
- ],
- )
借助這段代碼,便可通過命令bazel run :server 9999 /tmp/inception-v3/export/{timestamp}從容器中運行推斷服務器。
客戶端應用
由于gRPC是基于HTTP/2的,將來可能會直接從瀏覽器調用基于gRPC的服務,但除非主流的瀏覽器支持所需的HTTP/2特性,且谷歌發布瀏覽器端的JavaScript gRPC客戶端程序,從webapp訪問推斷服務都應當通過服務器端的組件進行。
接下來將基于BaseHTTPServer搭建一個簡單的Python Web服務器,BaseHTTPServer將處理上載的圖像文件,并將其發送給推斷服務進行處理,再將推斷結果以純文本形式返回。
為了將圖像發送到推斷服務器進行分類,服務器將以一個簡單的表單對GET請求做出響應。所使用的代碼如下:
- From BaseHTTPServer import HTTPServer,BaseHTTPRequestHandler
- import cgi
- import classification_service_pb2
- From grpc.beta import implementations
- class ClientApp (BaseHTTPRequestHandler);
- def do_GET(self):
- self.respond_form()
- def respond_form(self, response=""):
- form = """
- <html><body>
- <h1>Image classification service</h1>
- <form enctype="multipart/form-data" method="post">
- <div>Image: <input type="file" name="file"
- accept="image/jpeg"></div>
- <div><input type="submit" value="Upload"></div>
- </form>
- %s
- </body></html>
- """
- response = form % response
- self.send_response(200)
- self.send_header("Content-type", "text/html")
- self.send_header("Content-length", len(response))
- self.end_headers()
- self.wfile.write(response)
為了從Web App服務器調用推斷功能,需要ClassificationService相應的Python protocol buffer客戶端。為了生成它,需要運行Python的protocol buffer編譯器:
- pip install grpcio cython grpcio-tools
- python -m grpc.tools.protoc -I. --python_out=. --
- grpc_python_out=. classification_service.proto
它將生成包含了用于調用服務的stub的classification_service_pb2.py文件。
服務器接收到POST請求后,將對發送的表單進行解析,并用它創建一個Classification-Request對象。然后為這個分類服務器設置一個channel,并將請求提交給它。最后,它會將分類響應渲染為HTML,并送回給用戶。
- def do_POST(self):
- form = cgi.FieldStorage(
- fp=self.rfile,
- headers=self.headers,
- environ={
- 'REQUEST_METHOD': 'POST',
- 'CONTENT_TYPE': self.headers['Content-Type'],
- })
- request =
- classification_service_pb2.ClassificationRequest()
- request.input = form['file'].file.read()
- channel =
- implementations.insecure_channel("127.0.0.1", 9999)
- stub =
- classification_service_pb2.beta_create_ClassificationService_stub(channel)
- response = stub.classify(request, 10) # 10 secs
- timeout
- self.respond_form("<div>Response: %s</div>" %
- response)
為了運行該服務器,可從該容器外部使用命令python client.py。然后,用瀏覽器導航到http://localhost:8080來訪問其UI。請上傳一幅圖像并查看推斷結果如何。
產品準備
在結束本文內容之前,我們還將學習如何將分類服務器應用于產品中。
首先,將編譯后的服務器文件復制到一個容器內的永久位置,并清理所有的臨時構建文件:
- #在容器內部
- mkdir /opt/classification_server
- cd /mnt/home/serving_example
- cp -R bazel-bin/. /opt/classification_server
- bazel clean
現在,在容器外部,我們必須將其狀態提交給一個新的Docker鏡像,基本含義是創建一個記錄其虛擬文件系統變化的快照。
- #在容器外部
- docker ps
- #獲取容器ID
- docker commit <container id>
這樣,便可將圖像推送到自己偏好的docker服務云中,并對其進行服務。
本文小結
在本文中,我們學習了如何將訓練好的模型用于服務、如何將它們導出,以及如何構建可運行這些模型的快速、輕量級服務器;還學習了當給定了從其他App使用TensorFlow模型的完整工具集后,如何創建使用這些模型的簡單Web App。