可复现代码与模型服务
我们现在已经到了这样一个阶段:我们应该能够把我们的模型/训练代码分发给其他人,以便他们可以使用。你可以用一张软盘(floppy disk)把代码分发给别人,但这并不理想,对吧?也许很多年前这是理想的,但现在已经不是了。分享代码和与他人协作的首选方式是使用源代码管理系统(source code management system)。Git 是最流行的源代码管理系统之一。那么,假设你已经学会了 Git,把代码格式整理好了,写好了合适的文档,并且把你的项目开源(open-source)了。这就够了吗?不,还不够。这是因为你的代码是在你的电脑上写的,而由于许多不同的原因,它可能在别人的电脑上无法运行。所以,如果在你分发代码时能够复制(replicate)你的电脑环境,而别人在安装你的软件或运行你的代码时也能做到同样的事,那就太好了。要做到这一点,当今最流行的方法是使用 Docker 容器(Docker Container)。要使用 Docker 容器,你需要先安装 Docker。
让我们用下面的命令来安装 Docker。
$ sudo apt install docker.io
$ sudo systemctl start docker
$ sudo systemctl enable docker
$ sudo groupadd docker
$ sudo usermod -aG docker $USER
这些命令适用于 Ubuntu 18.04。Docker 最好的地方在于它可以安装在任意机器上:Linux、Windows、OSX。所以,只要你一直工作在 Docker 容器内部,你用的是哪台机器根本无所谓!
Docker 容器可以被看作小型虚拟机(virtual machine)。你可以为你的代码创建一个容器,然后每个人都能使用和访问它。让我们看看如何创建可以用来训练模型的容器。我们将使用在自然语言处理章节中训练过的 BERT 模型,并尝试把训练代码容器化(containerize)。
首先,你需要一个包含你的 Python 项目依赖(requirements)的文件。依赖项放在一个名为 requirements.txt 的文件里。这个文件名是标准约定。该文件包含你在项目中使用的所有 Python 库,也就是可以通过 PyPI(pip)下载的那些 Python 库。为了训练我们的 BERT 模型来检测正面/负面情感(sentiment),我们使用了 torch、transformers、tqdm、scikit-learn、pandas 和 numpy。让我们把它们写进 requirements.txt。你可以只写库的名字,也可以把版本号也写进去。最好总是包含版本号,这也是你应该做的。当你包含版本号时,就能确保别人使用的是和你一样的版本,而不是最新版本——因为最新版本可能会改变某些东西,如果是那样的话,模型的训练方式就不会和你训练时一样了。
下面的片段展示了 requirements.txt。
# requirements.txt
pandas==1.0.4
scikit-learn==0.22.1
torch==1.5.0
transformers==2.11.0
现在,我们将创建一个名为 Dockerfile 的 Docker 文件。没有扩展名。Dockerfile 有几个组成部分。让我们来看一看。
# Dockerfile
# First of all, we include where we are getting the image
# from. Image can be thought of as an operating system.
# You can do "FROM ubuntu:18.04"
# this will start from a clean ubuntu 18.04 image.
# All images are downloaded from dockerhub
# Here are we grabbing image from nvidia's repo
# they created a docker image using ubuntu 18.04
# and installed cuda 10.1 and cudnn7 in it. Thus, we don't have to
# install it. Makes our life easy.
FROM nvidia/cuda:10.1-cudnn7-runtime-ubuntu18.04
# this is the same apt-get command that you are used to
# except the fact that, we have -y argument. Its because
# when we build this container, we cannot press Y when asked for
RUN apt-get update && apt-get install -y \
git \
curl \
ca-certificates \
python3 \
python3-pip \
sudo \
&& rm -rf /var/lib/apt/lists/*
# We add a new user called "abhishek"
# this can be anything. Anything you want it
# to be. Usually, we don't use our own name,
# you can use "user" or "ubuntu"
RUN useradd -m abhishek
# make our user own its own home directory
RUN chown -R abhishek:abhishek /home/abhishek/
# copy all files from this direrctory to a
# directory called app inside the home of abhishek
# and abhishek owns it.
COPY --chown=abhishek *.* /home/abhishek/app/
# change to user abhishek
USER abhishek
RUN mkdir /home/abhishek/data/
# Now we install all the requirements
# after moving to the app directory
# PLEASE NOTE that ubuntu 18.04 image
# has python 3.6.9 and not python 3.7.6
# you can also install conda python here and use that
# however, to simplify it, I will be using python 3.6.9
# inside the docker container!!!!
RUN cd /home/abhishek/app/ && pip3 install -r requirements.txt
# install mkl. its needed for transformers
RUN pip3 install mkl
# when we log into the docker container,
# we will go inside this directory automatically
WORKDIR /home/abhishek/app
一旦我们创建好了 Docker 文件,就需要构建它。构建 Docker 容器是一个非常简单的命令。
docker build -f Dockerfile -t bert:train .
这条命令根据提供的 Dockerfile 构建一个容器。这个 Docker 容器的名字是 bert:train。它会产生如下输出:
❯ docker build -f Dockerfile -t bert:train .
Sending build context to Docker daemon 19.97kB
Step 1/7 : FROM nvidia/cuda:10.1-cudnn7-ubuntu18.04
---> 3b55548ae91f
Step 2/7 : RUN apt-get update && apt-get install -y \
git \
curl \
cacertificates \
python3 python3-pip \
sudo \
&& rm -rf /var/lib/apt/lists/*
. . . .
Removing intermediate container 8f6975dd08ba
---> d1802ac9f1b4
Step 7/7 : WORKDIR /home/abhishek/app
---> Running in 257ff09502ed
Removing intermediate container 257ff09502ed
---> e5f6eb4cddd7
Successfully built e5f6eb4cddd7
Successfully tagged bert:train
请注意,我从输出中删去了很多行。现在,你可以用下面的命令登录到容器中。
$ docker run -ti bert:train /bin/bash
你需要记住,你在这个 shell 里做的任何事情,一旦退出 shell 就会丢失。你可以用下面的命令在 Docker 容器内部运行训练:
$ docker run -ti bert:train python3 train.py
这会给出如下输出:
Traceback (most recent call last):
File "train.py", line 2, in <module>
import config
File "/home/abhishek/app/config.py", line 28, in <module>
do_lower_case=True
File "/usr/local/lib/python3.6/dist-packages/transformers/tokenization_utils.py", line 393, in from_pretrained
return cls._from_pretrained(*inputs, **kwargs)
File "/usr/local/lib/python3.6/dist-packages/transformers/tokenization_utils.py", line 496, in _from_pretrained
list(cls.vocab_files_names.values()),
OSError: Model name '../input/bert_base_uncased/' was not found in tokenizers model name list (bert-base-uncased, bert-large-uncased, bert-base-cased, bert-large-cased, bert-base-multilingual-uncased, bert-base-multilingual-cased, bert-base-chinese, bert-base-german-cased, bert-large-uncased-whole-word-masking, bert-large-cased-whole-word-masking, bert-large-uncased-whole-word-masking-finetuned-squad, bert-large-cased-whole-word-masking-finetuned-squad, bert-base-cased-finetuned-mrpc, bert-base-german-dbmdz-cased, bert-base-german-dbmdz-uncased, bert-base-finnish-cased-v1, bert-base-finnish-uncased-v1, bert-base-dutch-cased). We assumed '../input/bert_base_uncased/' was a path, a model identifier, or url to a directory containing vocabulary files named ['vocab.txt'] but couldn't find such vocabulary files at this path or url.
哎呀,出错了!
我为什么要在书里打印一个错误?
因为理解这个错误非常重要。这个错误说明代码找不到目录 ‘../input/bert_base_cased’。为什么会这样?不用 Docker 的时候我们明明能正常训练,而且我们可以看到这个目录和所有文件都存在。之所以会这样,是因为 Docker 就像一个虚拟机!它有自己的文件系统,你本地机器上的文件不会共享给 Docker 容器。如果你想使用本地机器上的某个路径,并且还想修改它,你需要在运行容器时把它挂载(mount)到 Docker 容器上。当我们查看这个文件夹路径时,我们知道它位于上一级的 input 文件夹中。让我们稍微修改一下 config.py 文件!
# config.py
import os
import transformers
# fetch home directory
# in our docker container, it is
# /home/abhishek
HOME_DIR = os.path.expanduser("~")
# this is the maximum number of tokens in the sentence
MAX_LEN = 512
# batch sizes is low because model is huge!
TRAIN_BATCH_SIZE = 8
VALID_BATCH_SIZE = 4
# let's train for a maximum of 10 epochs
EPOCHS = 10
# define path to BERT model files
# Now we assume that all the data is stored inside
# /home/abhishek/data
BERT_PATH = os.path.join(HOME_DIR, "data", "bert_base_uncased")
# this is where you want to save the model
MODEL_PATH = os.path.join(HOME_DIR, "data", "model.bin")
# training file
TRAINING_FILE = os.path.join(HOME_DIR, "data", "imdb.csv")
TOKENIZER = transformers.BertTokenizer.from_pretrained(
BERT_PATH,
do_lower_case=True
)
现在,代码假定所有内容都在 home 目录下一个名为 data 的文件夹内。
请注意,对 Python 脚本的任何修改,都意味着 Docker 容器需要重新构建!所以,我们重新构建容器并重新运行 Docker 命令,但这次有点不同。然而,如果我们没有 NVIDIA Docker 运行时(runtime),这次也不会成功。别担心。它同样只是一个 Docker 容器而已,而且你只需要做一次。要在 Ubuntu 18.04 上安装 NVIDIA Docker 运行时,你可以运行下面的命令。
# taken from: https://github.com/NVIDIA/nvidia-docker/
# Add the package repositories
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
sudo systemctl restart docker
现在我们可以重新构建容器并开始训练过程:
$ docker run --gpus 1 -v /home/abhishek/workspace/approaching_almost/input/:/home/abhishek/data/ -it bert:train python3 train.py
其中 –gpus 1 表示我们在 Docker 容器内使用 1 个 GPU,-v 表示挂载一个卷(volume)。所以,我们把本地目录 /home/abhishek/workspace/approaching_almost/input/ 挂载到 Docker 容器内的 /home/abhishek/data/。这一步会花一些时间,但当它完成时,你的本地文件夹里就会有 model.bin 了。
就这样,通过一些非常简单的修改,你现在已经『容器化』了你的训练代码。你现在可以拿着这份代码,在(几乎)任何你想要系统上训练了。
下一部分是把我们训练好的模型『服务』(serve)给最终用户。假设你想从不断涌入的推文流中提取情感。要做这种任务,你必须创建一个应用程序接口(API,Application Programming Interface),用来输入句子,然后返回带有情感概率的输出。用 Python 构建 API 最常见的方式是使用 Flask,它是一个微型 Web 服务框架(micro web service framework)。
# api.py
import config
import flask
import time
import torch
import torch.nn as nn
from flask import Flask
from flask import request
from model import BERTBaseUncased
app = Flask(__name__)
# init model to None
MODEL = None
# choose device
# please note that we are using cuda device
# you can also use cpu!
DEVICE = "cuda"
def sentence_prediction(sentence):
"""
A prediction function that takes an input sentence and
returns the probability for it being associated to a positive sentiment
"""
# fetch the tokenizer and max len of tokens from config.py
tokenizer = config.TOKENIZER
max_len = config.MAX_LEN
# the processing is same as it was done for training
review = str(sentence)
review = " ".join(review.split())
# encode the sentence into ids,
# truncate to max length &
# add CLS and SEP tokens
inputs = tokenizer.encode_plus(
review,
None,
add_special_tokens=True,
max_length=max_len
)
# fetch input ids, mask & token type ids
ids = inputs["input_ids"]
mask = inputs["attention_mask"]
token_type_ids = inputs["token_type_ids"]
# add padding if needed
padding_length = max_len - len(ids)
ids = ids + ([0] * padding_length)
mask = mask + ([0] * padding_length)
token_type_ids = token_type_ids + ([0] * padding_length)
# convert all the inputs to torch tensors
# we use unsqueeze(0) since we have only one sample
# this makes the batch size 1
ids = torch.tensor(ids, dtype=torch.long).unsqueeze(0)
mask = torch.tensor(mask, dtype=torch.long).unsqueeze(0)
token_type_ids = torch.tensor(token_type_ids, dtype=torch.long).unsqueeze(0)
# send everything to device
ids = ids.to(DEVICE, dtype=torch.long)
token_type_ids = token_type_ids.to(DEVICE, dtype=torch.long)
mask = mask.to(DEVICE, dtype=torch.long)
# use the model to make predictions
outputs = MODEL(ids=ids, mask=mask, token_type_ids=token_type_ids)
# take sigmoid of prediction and return the output
outputs = torch.sigmoid(outputs).cpu().detach().numpy()
return outputs[0][0]
@app.route("/predict", methods=["GET"])
def predict():
# this is our endpoint!
# this endpoint can be accessed by http://HOST:PORT/predict
# the endpoint needs sa sentence and can only use GET
# POST request is not allowed
sentence = request.args.get("sentence")
# keep track of time
start_time = time.time()
# make prediction
positive_prediction = sentence_prediction(sentence)
# negative = 1 - positive
negative_prediction = 1 - positive_prediction
# create return dictionary
response = {}
response["response"] = {
"positive": str(positive_prediction),
"negative": str(negative_prediction),
"sentence": str(sentence),
"time_taken": str(time.time() - start_time),
}
# we use jsonify from flask for dictionaries
return flask.jsonify(response)
if __name__ == "__main__":
# init the model
MODEL = BERTBaseUncased()
# load the dictionary
MODEL.load_state_dict(torch.load(
config.MODEL_PATH,
map_location=torch.device(DEVICE)
))
# send model to device
MODEL.to(DEVICE)
# put model in eval mode
MODEL.eval()
# start the application
# 0.0.0.0 means that this endpoint can be
# accessed from all computers in a network
app.run(host="0.0.0.0")
你可以通过运行命令 ‘python api.py’ 来启动这个 API。API 将在 localhost 的 5000 端口上启动。
一个示例 cURL 请求及其响应如下所示。
❯ curl $'http://192.168.86.48:5000/predict?sentence=this%20is%20the%20best%20book%20ever'
{"response":{"negative":"0.0032927393913269043","positive":"0.99670726","sentence":"this is the best book ever","time_taken":"0.029126882553100586"}}
如你所见,对于提供的输入句子,我们得到了很高的正面情感概率。你也可以在你最喜欢的浏览器中访问 http://127.0.0.1:5000/predict?sentence=this%20book%20is%20too%20complicated%20for%20me 来查看结果。这同样会返回一个 JSON。
{
"response": {
"negative": "0.8646619468927383",
"positive": "0.13533805",
"sentence": "this book is too complicated for me",
"time_taken": "0.03852701187133789"
}
}
现在,我们已经创建了一个简单的 API,可以用它为少量用户提供服务。为什么是少量?因为这个 API 一次只能服务一个请求。让我们改用 CPU,并使用 gunicorn(一个用于 UNIX 的 Python WSGI HTTP 服务器)让它能处理许多并行请求。Gunicorn 可以为 API 创建多个进程,因此我们可以同时服务许多客户。你可以用 ‘pip install gunicorn’ 来安装 gunicorn。
要让代码兼容 gunicorn,我们需要删除 if main 部分,并把其中的所有内容移到全局作用域(global scope)。另外,我们现在使用 CPU 而不是 GPU。请看下面的修改后代码。
# api.py
import config
import flask
import time
import torch
import torch.nn as nn
from flask import Flask
from flask import request
from model import BERTBaseUncased
app = Flask(__name__)
# now we use cpu!
DEVICE = "cpu"
# init the model
MODEL = BERTBaseUncased()
# load the dictionary
MODEL.load_state_dict(torch.load(
config.MODEL_PATH,
map_location=torch.device(DEVICE)
))
# send model to device
MODEL.to(DEVICE)
# put model in eval mode
MODEL.eval()
def sentence_prediction(sentence):
"""
A prediction function that takes an input sentence and
returns the probability for it being associated to a positive sentiment
"""
. . .
return outputs[0][0]
@app.route("/predict", methods=["GET"])
def predict():
# this is our endpoint!
. . .
return flask.jsonify(response)
我们用下面的命令来运行这个 API。
$ gunicorn api:app --bind 0.0.0.0:5000 --workers 4
这意味着我们用 4 个 worker(工作进程)在给定的 IP 地址和端口上运行我们的 Flask 应用。因为有 4 个 worker,我们现在可以同时服务 4 个请求。请注意,现在我们的接口使用 CPU,因此它不需要 GPU 机器,可以在任何标准服务器/虚拟机上运行。不过,我们还有一个问题:我们是在本地机器上完成所有这些工作的,所以我们必须把它容器化。请看下面这个没有注释的 Dockerfile,它可以用来部署这个 API。
注意旧的训练用 Dockerfile 和这一个之间的区别。差别并不大。
# CPU Dockerfile
FROM ubuntu:18.04
RUN apt-get update && apt-get install -y \
git \
curl \
ca-certificates \
python3 \
python3-pip \
sudo \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m abhishek
RUN chown -R abhishek:abhishek /home/abhishek/
COPY --chown=abhishek *.* /home/abhishek/app/
USER abhishek
RUN mkdir /home/abhishek/data/
RUN cd /home/abhishek/app/ && pip3 install -r requirements.txt
RUN pip3 install mkl
WORKDIR /home/abhishek/app
让我们构建一个新的 Docker 容器。
$ docker build -f Dockerfile -t bert:api .
当 Docker 容器构建完成后,我们现在可以直接用下面的命令运行 API。
$ docker run -p 5000:5000 -v /home/abhishek/workspace/approaching_almost/input/:/home/abhishek/data/ -it bert:api /home/abhishek/.local/bin/gunicorn api:app --bind 0.0.0.0:5000 --workers 4
请注意,我们把容器的 5000 端口暴露到容器外的 5000 端口。如果你使用 docker-compose,也可以用一种更优雅的方式做到这一点。Docker Compose 是一个工具,可以让你同时在不同或相同的容器中运行不同的服务。你可以用 ‘pip install docker-compose’ 安装 docker-compose,然后在构建容器后运行 ‘docker-compose up’。要使用 docker-compose,你需要一个 docker-compose.yml 文件。
# docker-compose.yml
# specify a version of the compose
version: '3.7'
# you can add multiple services
services:
# specify service name. we call our service: api
api:
# specify image name
image: bert:api
# the command that you would like to run inside the container
command: /home/abhishek/.local/bin/gunicorn api:app --bind 0.0.0.0:5000 --workers 4
# mount the volume
volumes:
- /home/abhishek/workspace/approaching_almost/input/:/home/abhishek/data/
# this ensures that our ports from container will be
# exposed as it is
network_mode: host
现在你可以只用上面提到的命令重新运行 API,它的工作方式和之前一样。恭喜!现在你也成功地把预测 API 容器化了,它可以部署到你想要的任何地方。在本章中,我们学习了 Docker、用 Flask 构建 API、用 gunicorn 以及 Docker 和 docker-compose 来服务 API。Docker 的内容远不止我们在这里看到的这些,但这应该足以让你起步。剩下的可以在你逐步深入的过程中学习。我们还略过了许多工具,比如 kubernetes、bean-stalk、sagemaker、heroku,以及其他许多人们如今用于在生产环境部署模型的工具。『我要写些什么?点击图 X 中的修改 docker 容器』?在一本书里描述这些工具既不可行也不可取,所以我会用另一种媒介来补充本书的这一部分。记住,一旦你容器化了你的应用,用这些技术/平台中的任何一种来部署都是小菜一碟。永远记住,要让你的代码和模型对他人可用、有良好的文档记录,这样任何人都能使用你开发的东西,而不必反复来问你。这会节省你的时间,也会节省他们的时间。好的、开源的、可复用的代码放在你的作品集(portfolio)里也很好看。