28 | PyTorch构建的模型如何上线部署

在模型实际的应用中,一般有两种使用方法,一个是跑批数据,就像我们之前跑验证集那样。比如说我们收集到了很多需要去分类的图像,然后一次性的导入并使用我们训练好的模型给出结果,预测完这一批之后程序就自动关闭了,等到下一次我们有需要的时候再启动。另外一种就是应用于线上服务,构建一个服务等待新的请求,当有请求发起的时候就接收数据,然后给出结果,在没有请求的时候,模型服务仍然处于运行的状态,只不过是等待下一个请求。

Flask框架

关于一次性处理批数据,我们前面的流程基本可以满足了,这里介绍一个在线实时服务。FLask框架是一个用Python编写的Web微服务框架,Flask的使用十分简单,在日常开发中可以快速地实现一个Web服务,而且灵活度很高。

首先安装Flask。

pip install Flask

等待安装完之后,就可以编写代码了,假设我们写一个python脚本名字是flask_hello_world.py,内容如下

from flask import Flask
app = Flask(__name__)

@app.route("/hello")
def hello():
    return "Hello World!"

if __name__ == '__main__':
    app.run()

然后在shell里面运行它,这里我们在run方法里面没有设置参数,就会使用默认的127.0.0.1 host地址和5000端口,启动成功可以看到下面的显示


image.png

这个时候在浏览器中打开它,输入127.0.0.1:5000/hello,即可看到输出的结果“Hello World!”,这就完成了一个最简单的web服务。


image.png

如果要让它实现模型运算,重点就是去修改hello方法。
import numpy as np
import sys
import os
import torch
from flask import Flask, request, jsonify
import json

from p2ch13.model_cls import LunaModel

app = Flask(__name__)
#加载模型
model = LunaModel()
model.load_state_dict(torch.load(sys.argv[1],
                                 map_location='cpu')['model_state'])
model.eval()
#运行推理部分
def run_inference(in_tensor):
    with torch.no_grad():
        # LunaModel 接收批量数据并输出一个元组 (scores, probs)
        out_tensor = model(in_tensor.unsqueeze(0))[1].squeeze(0)
    probs = out_tensor.tolist()
    out = {'prob_malignant': probs[1]}
    return out

@app.route("/predict", methods=["POST"])
#预测方法的逻辑
def predict():
#使用request接收数据
    meta = json.load(request.files['meta'])
    blob = request.files['blob'].read()
#转换成tensor
    in_tensor = torch.from_numpy(np.frombuffer(
        blob, dtype=np.float32))
    in_tensor = in_tensor.view(*meta['shape'])
#推理,输出
    out = run_inference(in_tensor)
#返回结果
    return jsonify(out)

if __name__ == '__main__':
    app.run()
    print (sys.argv[1])

这样就已经写好了最简单的服务代码,然后运行它


image.png

这时候我们就已经启动了web服务,当然我们这里处理的比较简单,在真实场景下通常都是后台运行,并且要增加日志输出和报警系统,防止出现各种问题而服务中断。然后模拟客户端向服务端发送请求,很快就得到了结果,当然这里有一份预先准备好的数据,不然光数据处理就要花好多时间。


image.png

可以看到恶性肿瘤的可能性不大。到这里,我们就完成了一个简单的模型部署流程,当然,这里只是一个单一的服务,如果我们在工作中需要用到并发服务,异步服务可以在这个基础上进行修改,或者搭配其他的工具。比如说要实现并发服务,我们可以在服务器上启动多个服务,然后搭配Nginx实现负载均衡。

Sanic框架

然后我们再来介绍一个异步处理框架Sanic。现在是一个高并发的时代,并发量是在构建服务时必须考量的一个指标。所以我们自然就想到了 Python 中的异步框架,Sanic 的表现十分出色,使用 Sanic 构建的应用程序足以比肩 Nodejs。如果你再对 Sanic 在路由处理方面使用 C 语言做一些重构,那么并发性能可以和 Go 相媲美。


image.png

异步并发的流程大概像上图描述的样子,多个客户端发起请求,这些请求会进入一个任务队列,然后这些任务的数据组成一个批数据传给模型,模型给出预测结果,然后由请求处理器拆分结果并分别回传给不同的客户端。使用这种方式有助于提高我们的模型工作效率。

首先安装Sanic。

pip install sanic

接下来就是使用sanic完成一个异步服务。我们这里使用的是把马变成斑马的模型。来看看代码,首先是一些引用项。

import sys
import asyncio
import itertools
import functools
from sanic import Sanic
from sanic.response import  json, text
from sanic.log import logger
from sanic.exceptions import ServerError

import sanic
import threading
import PIL.Image
import io
import torch
import torchvision
from .cyclegan import get_pretrained_model

定义一些全局变量或者参数。

#实例sanic
app = Sanic(__name__)
#设置使用的设备为cpu
device = torch.device('cpu')
# we only run 1 inference run at any time (one could schedule between several runners if desired)
MAX_QUEUE_SIZE = 3  # 队列最大长度
MAX_BATCH_SIZE = 2  # 批数据的最大长度
MAX_WAIT = 1        # 最大等待时间

异常处理类

class HandlingError(Exception):
    def __init__(self, msg, code=500):
        super().__init__()
        self.handling_code = code
        self.handling_msg = msg

模型运行类

class ModelRunner:
    def __init__(self, model_name):
#首先是模型运行的初始化
        self.model_name = model_name
#声明使用的队列
        self.queue = []
#声明队列锁
        self.queue_lock = None
#加载模型
        self.model = get_pretrained_model(self.model_name,
                                          map_location=device)
#是否运行的标记
        self.needs_processing = None
#是否使用计时器
        self.needs_processing_timer = None

调度运行信号处理

    def schedule_processing_if_needed(self):
#判断队列长度是否已经超过批大小
        if len(self.queue) >= MAX_BATCH_SIZE:
            logger.debug("next batch ready when processing a batch")
#如果队列长度够长,把运行标记设置为需要运行
            self.needs_processing.set()
#否则判断,如果队列不为空,查看计时器
        elif self.queue:
            logger.debug("queue nonempty when processing a batch, setting next timer")
            self.needs_processing_timer = app.loop.call_at(self.queue[0]["time"] + MAX_WAIT, self.needs_processing.set)

处理输入数据并判断是否需要运行

    async def process_input(self, input):
        our_task = {"done_event": asyncio.Event(loop=app.loop),
                    "input": input,
                    "time": app.loop.time()}
        async with self.queue_lock:
            if len(self.queue) >= MAX_QUEUE_SIZE:
                raise HandlingError("I'm too busy", code=503)
            self.queue.append(our_task)
            logger.debug("enqueued task. new queue size {}".format(len(self.queue)))
            self.schedule_processing_if_needed()
#等等处理完成
        await our_task["done_event"].wait()
        return our_task["output"]

运行模型

    def run_model(self, batch): 
        return self.model(batch.to(device)).to('cpu')
    async def model_runner(self):
        self.queue_lock = asyncio.Lock(loop=app.loop)
        self.needs_processing = asyncio.Event(loop=app.loop)
        logger.info("started model runner for {}".format(self.model_name))
#while True 无限循环,程序会处于监听状态
        while True:
#等待有任务来
            await self.needs_processing.wait()
            self.needs_processing.clear()
#清空计时器
            if self.needs_processing_timer is not None:
                self.needs_processing_timer.cancel()
                self.needs_processing_timer = None
#处理队列都开启锁
            async with self.queue_lock:
#如果队列不为空则设置最长等待时间
                if self.queue:
                    longest_wait = app.loop.time() - self.queue[0]["time"]
                else:  # oops
                    longest_wait = None
#日志记录启动处理,队列大小,等待时间
                logger.debug("launching processing. queue size: {}. longest wait: {}".format(len(self.queue), longest_wait))
#获取一个批次的数据
                to_process = self.queue[:MAX_BATCH_SIZE]
#然后把这些数据从任务队列中删除
                del self.queue[:len(to_process)]
                self.schedule_processing_if_needed()
           #生成批数据
            batch = torch.stack([t["input"] for t in to_process], dim=0)
#在一个单独的线程中运行模型,然后返回结果
            result = await app.loop.run_in_executor(
                None, functools.partial(self.run_model, batch)
            )
#记录结果并设置一个完成事件
            for t, r in zip(to_process, result):
                t["output"] = r
                t["done_event"].set()
            del to_process

类实例化

style_transfer_runner = ModelRunner(sys.argv[1])

最后是处理网络交互

#路由策略
@app.route('/image', methods=['PUT'], stream=True)
#处理请求
async def image(request):
    try:
#输出报头
        print (request.headers)
        content_length = int(request.headers.get('content-length', '0'))
#定义接收数据最大值
        MAX_SIZE = 2**22 # 10MB
#如果接收数据超标返回异常信息
        if content_length:
            if content_length > MAX_SIZE:
                raise HandlingError("Too large")
#初始化数据接收
            data = bytearray(content_length)
        else:
            data = bytearray(MAX_SIZE)
        pos = 0
#这里也是True,一直处于监听状态
        while True:
      #读取数据包
            data_part = await request.stream.read()
            if data_part is None:
                break
#数据包拼接到data里面
            data[pos: len(data_part) + pos] = data_part
            pos += len(data_part)
            if pos > MAX_SIZE:
                raise HandlingError("Too large")
#然后开始对接收的图像数据进行预处理
        im = PIL.Image.open(io.BytesIO(data))
        im = torchvision.transforms.functional.resize(im, (228, 228))
        im = torchvision.transforms.functional.to_tensor(im)
        im = im[:3]  # drop alpha channel if present
        if im.dim() != 3 or im.size(0) < 3 or im.size(0) > 4:
            raise HandlingError("need rgb image")
#使用实例化的模型程序处理图像
        out_im = await style_transfer_runner.process_input(im)
#结果转化为图像信息
        out_im = torchvision.transforms.functional.to_pil_image(out_im)
        imgByteArr = io.BytesIO()
        out_im.save(imgByteArr, format='JPEG')
        return sanic.response.raw(imgByteArr.getvalue(), status=200,
                                  content_type='image/jpeg')
    except HandlingError as e:
        # we don't want these to be logged...
        return sanic.response.text(e.handling_msg, status=e.handling_code)

启动服务部分

app.add_task(style_transfer_runner.model_runner())
app.run(host="0.0.0.0", port=8000,debug=True)

看完代码,我们把它启动起来。


image.png

使用curl把图像数据传到web服务中,并设定了输出结果到res1.jpg中


image.png

去对应的位置查看,果然新生成了一张图片,可见我们的服务运行良好。
image.png

当然这里弄的两个实现方案都挺简单的,不过核心部分基本都介绍到了,在实际的工作中就是在这个基础上修修补补敲敲打打差不多就可以满足需求。

历时一个半月,终于把这本书看完了,英文原版写的挺好,由浅入深,但是这个翻译实在是有点烂,有需要英文原版电子书的留下邮箱。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容