在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署日萌社

人工智能AI:Keras PyTorch MXNet TensorFlow PaddlePaddle 深度学习实战(不定时更新)


智能对话系统:Unit对话API

在线聊天的总体架构与工具介绍:Flask web、Redis、Gunicorn服务组件、Supervisor服务监控器、Neo4j图数据库

linux 安装 neo4jlinux 安装 Redissupervisor 安装

neo4j图数据库:Cypher

neo4j图数据库:结构化数据流水线、非结构化数据流水线

命名实体审核任务:BERT中文预训练模型

命名实体审核任务:构建RNN模型

命名实体审核任务:模型训练

命名实体识别任务:BiLSTM+CRF part1

命名实体识别任务:BiLSTM+CRF part2

命名实体识别任务:BiLSTM+CRF part3

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

系统联调测试与部署

离线部分+在线部分:命名实体审核任务RNN模型、命名实体识别任务BiLSTM+CRF模型、BERT中文预训练+微调模型、werobot服务+flask


7.1 在线部分简要分析


  • 学习目标:
    • 了解在线部分的核心组成.
    • 了解各个核心组成部分的作用.

  • 在线部分架构图:

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 在线部分简要分析:
    • 根据架构图,在线部分的核心由三个服务组成,分别是werobot服务,主要逻辑服务,句子相关模型服务. 这三个服务贯穿连接整个在线部分的各个模块.

  • werobot服务作用:
    • 用于连接微信客户端与后端服务, 向主要逻辑服务发送用户请求,并接收结构返回给用户.

  • 主要逻辑服务作用:
    • 用于处理核心业务逻辑, 包括会话管理,请求句子相关模型服务,查询图数据库,调用Unit API等.

  • 句子相关模型服务:
    • 用于封装训练好的句子相关判断模型, 接收来自主要逻辑服务的请求, 返回判断结果.

7.2 werobot服务构建


  • 学习目标:
    • 掌握werobot服务的构建过程.

  • werobot服务的构建过程可分为四步:
    • 第一步: 获取服务器公网IP
    • 第二步: 配置微信公众号
    • 第三步: 使用werobot启动服务脚本
    • 第四步: 使用微信公众号进行测试

  • 第一步: 获取服务器公网IP
  • 登陆阿里云官网(https://www.aliyun.com/product/ecs):

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 进行基本配置, 选择所在地域, 实例类型, 镜像, 存储, 购买时长

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 选择网络和安全组(默认配置)

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 设置密码, 实例名称, 主机名

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 配置分组设置(默认配置)

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 确认订单并支付

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 查看服务器公网IP

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 第二步: 使用公网IP配置微信公众号
  • 注册微信订阅号(https://mp.weixin.qq.com), 并在基本配置中进行URL和Token的设定

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 第三步: 使用werobot启动服务脚本
  • 安装werobot
pip install werobot

  • 进行启动脚本的编写
# 导入werobot和发送请求的requests
import werobot
import requests

# 主要逻辑服务请求地址
url = "http://xxx.xxx.xxx.xxx:5000/v1/main_serve/"

# 服务超时时间
TIMEOUT = 3

# 声明微信访问请求【框架将辅助完成微信联通验证】
robot = werobot.WeRoBot(token="doctoraitoken")

# 设置所有请求(包含文本、语音、图片等消息)入口
@robot.handler
def doctor(message, session):
    try:
        # 获得用户uid
        uid = message.source
        try:
            # 检查session,判断该用户是否第一次发言
            # 初始session为{}
            # 如果session中没有{uid:"1"}
            if session.get(uid, None) != "1":
                # 将添加{uid:"1"}
                session[uid] = "1"
                # 并返回打招呼用语
                return '您好, 我是智能客服小艾, 有什么需要帮忙的吗?'
            # 获取message中的用户发言内容
            text = message.content
        except:
            # 这里使用try...except是因为我用户很可能出现取消关注又重新关注的现象
            # 此时通过session判断,该用户并不是第一次发言,会获取message.content
            # 但用户其实又没有说话, 获取message.content时会报错
            # 该情况也是直接返回打招呼用语
            return '您好, 我是智能客服小艾, 有什么需要帮忙的吗 ?'
        # 获得发送主要逻辑服务的数据体
        data = {"uid": uid, "text": text}
        # 向主要逻辑服务发送post请求
        res = requests.post(url, data=data, timeout=TIMEOUT)
        # 返回主要逻辑服务的结果
        return res.text
    except Exception as e:
        print("出现异常:", e)
        return "对不起, 机器人客服正在休息..."

# 让服务器监听在 0.0.0.0:80
robot.config["HOST"] = "0.0.0.0"
robot.config["PORT"] = 80
robot.run()
  • 启动服务脚本
python /data/wr.py

  • 第四步: 使用微信进行测试
  • 小节总结:
    • 学习了werobot服务的构建过程:
      • 第一步: 获取服务器公网IP
      • 第二步: 配置微信公众号
      • 第三步: 使用werobot启动服务脚本
      • 第四步: 使用微信公众号进行测试

7.3 主要逻辑服务


  • 学习目标:
    • 了解该服务中的主要逻辑.
    • 掌握构建主要逻辑服务的过程.

  • 主要逻辑图:

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 逻辑图分析:

    • 主要逻辑服务接收werobot发送的请求后,根据用户id查询redis查找用户上一次说过的话,根据结果判断是否为他的第一句.

    • 如果是第一句话,直接查询数据库,判断句子中是否包含症状实体,并返回该症状连接的疾病,并填充在规则对话模版中,如果查询不到则调用Unit API返回结果.

    • 如果不是该用户的第一句话,则连同上一句话的内容一起请求句子相关模型服务,判断两句话是否讨论同一主题,如果是,则继续查询图数据库,如果不是,使用unit api返回结果.


  • 构建主要逻辑服务的步骤:
    • 第一步: 导入必备工具和配置
    • 第二步: 完成查询neo4j数据库的函数
    • 第三步: 编写主要逻辑处理类
    • 第四步: 编写服务中的主函数
    • 第五步: 使用gunicorn启动服务
    • 第六步: 编写测试脚本并进行测试:

  • 第一步: 导入必备工具和配置
# 服务框架使用Flask
# 导入必备的工具
from flask import Flask
from flask import request
app = Flask(__name__)

# 导入发送http请求的requests工具
import requests

# 导入操作redis数据库的工具
import redis

# 导入加载json文件的工具
import json

# 导入已写好的Unit API调用文件
from unit import unit_chat

# 导入操作neo4j数据库的工具
from neo4j import GraphDatabase

# 从配置文件中导入需要的配置
# NEO4J的连接配置
from config import NEO4J_CONFIG
# REDIS的连接配置
from config import REDIS_CONFIG
# 句子相关模型服务的请求地址
from config import model_serve_url
# 句子相关模型服务的超时时间
from config import TIMEOUT
# 规则对话模版的加载路径
from config import reply_path
# 用户对话信息保存的过期时间
from config import ex_time

# 建立REDIS连接池
pool = redis.ConnectionPool(**REDIS_CONFIG)

# 初始化NEO4J驱动对象
_driver = GraphDatabase.driver(**NEO4J_CONFIG)
  • 配置文件内容如下:
REDIS_CONFIG = {
     "host": "0.0.0.0",
     "port": 6379
}


NEO4J_CONFIG = {
    "uri": "bolt://0.0.0.0:7687",
    "auth": ("neo4j", "********"),
    "encrypted": False
}

model_serve_url = "http://0.0.0.0:5001/v1/recognition/"

TIMEOUT = 2

reply_path = "./reply.json"

ex_time = 36000
  • 规则对话模版文件reply.json内容如下:
{
"1": "亲爱的用户, 在线医生一个医患问答机器人,请您说一些当前的症状吧!",
"2": "根据您当前的症状描述, 您可能患有以下疾病, %s, 再想想还有更多的症状吗?",
"3": "对不起, 您所说的内容超出了在线医生的知识范围. 请尝试换一些描述方式!",
"4": "您的这次描述并没有给我带来更多信息,请您继续描述您的症状."
}
  • 第二步: 完成查询neo4j数据库的函数
def query_neo4j(text):
    """
    description: 根据用户对话文本中的可能存在的症状查询图数据库.
    :param text: 用户的输入文本.
    :return: 用户描述的症状对应的疾病列表.
    """
    # 开启一个session操作图数据库 
    with _driver.session() as session:
         # cypher语句, 匹配句子中存在的所有症状节点, 
         # 保存这些节点并逐一通过关系dis_to_sym进行对应病症的查找, 返回找到的疾病名字列表.
        cypher = "MATCH(a:Symptom) WHERE(%r contains a.name) WITH \
                  a MATCH(a)-[r:dis_to_sym]-(b:Disease) RETURN b.name LIMIT 5" %text
        # 运行这条cypher语句
        record = session.run(cypher)
        # 从record对象中获得结果列表
        result = list(map(lambda x: x[0], record))
    return result
  • 调用:
if __name__ == "__main__":
    text = "我最近腹痛!"
    result = query_neo4j(text)
    print("疾病列表:", result)

  • 输出效果:
疾病列表: ['胃肠道癌转移卵巢', '胃肠道功能紊乱', '胃肠积液', '胃肠型食物中毒', '胃结核']

  • 第三步: 编写主要逻辑处理类
class Handler(object):
    """主要逻辑服务的处理类"""
    def __init__(self, uid, text, r, reply):
        """
        :param uid: 用户唯一标示uid
        :param text: 该用户本次输入的文本
        :param r: redis数据库的连接对象
        :param reply: 规则对话模版加载到内存的对象(字典)
        """
        self.uid = uid
        self.text = text
        self.r = r
        self.reply = reply

    def non_first_sentence(self, previous):
        """
        description: 非首句处理函数
        :param previous: 该用户当前句(输入文本)的上一句文本
        :return: 根据逻辑图返回非首句情况下的输出语句
        """
        # 尝试请求模型服务, 若失败则打印错误结果
        try:
            data = {"text1": previous, "text2": self.text}
            result = requests.post(model_serve_url, data=data, timeout=TIMEOUT)
            if not result.text: return unit_chat(self.text)
        except Exception as e:
            print("模型服务异常:", e)
            return unit_chat(self.text)
        # 继续查询图数据库, 并获得结果
        s = query_neo4j(self.text)
        # 判断结果为空列表, 则直接使用UnitAPI返回
        if not s: return unit_chat(self.text)
        # 若结果不为空, 获取上一次已回复的疾病old_disease
        old_disease = self.r.hget(str(self.uid), "previous_d")
        if old_disease:
            # new_disease是本次需要存储的疾病, 是已经存储的疾病与本次查询到疾病的并集
            new_disease = list(set(s) | set(eval(old_disease)))
            # res是需要返回的疾病, 是本次查询到的疾病与已经存储的疾病的差集
            res = list(set(s) - set(eval(old_disease)))
        else:
            # 如果old_disease为空, 则它们相同都是本次查询结果s
            res = new_disease = list(set(s))

        # 存储new_disease覆盖之前的old_disease
        self.r.hset(str(self.uid), "previous_d", str(new_disease))
        # 设置过期时间
        self.r.expire(str(self.uid), ex_time)
        # 将列表转化为字符串, 添加到规则对话模版中返回
        if not res:
            return self.reply["4"]
        else:
            res = ",".join(res)
            return self.reply["2"] %res


    def first_sentence(self):
        """首句处理函数"""
        # 直接查询图数据库, 并获得结果
        s = query_neo4j(self.text)
        # 判断结果为空列表, 则直接使用UnitAPI返回
        if not s: return unit_chat(self.text)
        # 将s存储为"上一次返回的疾病"
        self.r.hset(str(self.uid), "previous_d", str(s))
        # 设置过期时间
        self.r.expire(str(self.uid), ex_time)
        # 将列表转化为字符串, 添加到规则对话模版中返回
        res = ",".join(s)
        return self.reply["2"] %res
关于非首句的处理逻辑
- 1: 第一句话: 我最近头疼。(相关的语句, 返回的疾病列表A, B, C都已经在redis里面了 - previous_d)
- 2: 第二句话: 扁桃体发炎。
  - 2.1: 先去查neo4j数据库, 返回的是"扁桃体发炎"的疾病列表D, E, F, A
  - 2.2: 直接去redis数据库中将A, B, C查出来, old_disease = [A, B, C]
  - 2.3: 将两次查询的疾病做并集 new_disease = [A, B, C, D, E, F]
  - 2.4: 因为上一次回复给病人的疾病, 不应该再次返回, 所以做差集的计算 res = [A, D, E, F] - [A, B, C] = [D, E, F]
  - 2.5: 将new_disease写入到redis中, 同时覆盖掉同一个用户的old_disease。
  - 2.6: 返回给用户的列表本质上就是res = [D, E, F]
- 3: 第三句话: 有新冠肺炎的发冷感觉, 心理压力大。
  - 3.1: 先去查neo4j数据库, 返回的是"新冠肺炎, 发冷, 心理压力大"的疾病列表D, E, H, W
  - 3.2: 直接去redis数据库中将ABCDEF查出来, old_disease = [A, B, C, D, E, F]
  - 3.3: 将三次查询的疾病做并集 new_disease = [A, B, C, D, E, F, H, W]
  - 3.4: 所有之前回复给病人的疾病, 不应该再次返回, 所以做差集的计算 res = [D, E, H, W] - [A, B, C, D, E, F] = [H, W]
  - 3.5: 将new_disease写入到redis中, 同时覆盖掉同一个用户的old_disease。
  - 3.6: 返回给用户的列表本质上就是res = [H, W]
  • 第四步: 编写服务中的主函数
# 设定主要逻辑服务的路由和请求方法
@app.route('/v1/main_serve/', methods=["POST"])
def main_serve():
    # 接收来自werobot服务的字段
    uid = request.form['uid']
    text = request.form['text']
    # 从redis连接池中获得一个活跃连接
    r = redis.StrictRedis(connection_pool=pool)
    # 根据该uid获取他的上一句话(可能不存在)
    previous = r.hget(str(uid), "previous")
    # 将当前输入的文本设置成上一句
    r.hset(str(uid), "previous", text)
    # 读取规则对话模版内容到内存
    reply = json.load(open(reply_path, "r"))
    # 实例化主要逻辑处理对象
    handler = Handler(uid, text, r, reply)
    # 如果previous存在, 说明不是第一句话
    if previous:
        # 调用non_first_sentence方法
        return handler.non_first_sentence(previous)
    else:
        # 否则调用first_sentence()方法
        return handler.first_sentence()

  • 第五步: 使用gunicorn启动服务
gunicorn -w 1 -b 0.0.0.0:5001 app:app
# -w 代表开启的进程数, 我们只开启一个进程
# -b 服务的IP地址和端口
# app:app 是指执行的主要对象位置, 在app.py中的app对象
  • 第六步: 编写测试脚本并进行测试
  • 编写测试脚本:
import requests

# 定义请求url和传入的data
url = "http://0.0.0.0:5000/v1/main_serve/"
data = {"uid":"13424", "text": "头晕"}

# 向服务发送post请求
res = requests.post(url, data=data)
# 打印返回的结果
print(res.text)

  • 运行脚本:
python test.py

  • 输出效果:
根据您当前的症状描述, 您可能患有以下疾病, 中毒,虫媒传染病,小儿肥厚型心肌病,血红蛋白E病,铍中毒, 再想想还有更多的症状吗?

  • 小节总结:

    • 学习了服务的主要逻辑:
      • 主要逻辑服务接收werobot发送的请求后,根据用户id查询redis查找用户上一次说过的话,根据结果判断是否为他的第一句.
      • 如果是第一句话,直接查询数据库,判断句子中是否包含症状实体,并返回该症状连接的疾病,并填充在规则对话模版中,如果查询不到则调用Unit API返回结果.
      • 如果不是该用户的第一句话,则连同上一句话的内容一起请求句子相关模型服务,判断两句话是否讨论同一主题,如果是,则继续查询图数据库,如果不是,使用unit api返回结果.

    • 构建主要逻辑服务的步骤:
      • 第一步: 导入必备工具和配置
      • 第二步: 完成查询neo4j数据库的函数
      • 第三步: 编写主要逻辑处理类
      • 第四步: 编写服务中的主函数
      • 第五步: 使用gunicorn启动服务
      • 第六步: 编写测试脚本并进行测试

"""
构建主要逻辑服务的步骤:
        第一步: 导入必备工具和配置
        第二步: 完成查询neo4j数据库的函数
        第三步: 编写主要逻辑处理类
        第四步: 编写服务中的主函数
        第五步: 使用gunicorn启动服务
        第六步: 编写测试脚本并进行测试:
"""
#-------------------------------------------第一步: 导入必备工具和配置----------------------------------#
# 服务框架使用Flask
# 导入必备的工具
from flask import Flask
from flask import request
app = Flask(__name__)

# 导入发送http请求的requests工具
import requests
# 导入操作redis数据库的工具
import redis
# 导入加载json文件的工具
import json
# 导入已写好的Unit API调用文件
from unit import unit_chat
# 导入操作neo4j数据库的工具
from neo4j import GraphDatabase
# 从配置文件中导入需要的配置
# NEO4J的连接配置
# from config import NEO4J_CONFIG
# # REDIS的连接配置
# from config import REDIS_CONFIG
# 句子相关模型服务的请求地址
from config import model_serve_url
# 句子相关模型服务的超时时间
from config import TIMEOUT
# 规则对话模版的加载路径
from config import reply_path
# 用户对话信息保存的过期时间
from config import ex_time

REDIS_CONFIG = {
     "host": "0.0.0.0",
     "port": 6379
}
NEO4J_CONFIG = {
    "uri": "bolt://0.0.0.0:7687",
    "auth": ("neo4j", "********"),
    "encrypted": False
}
model_serve_url = "http://0.0.0.0:5001/v1/recognition/"
TIMEOUT = 2
reply_path = "./reply.json"
ex_time = 36000
# 建立REDIS连接池
pool = redis.ConnectionPool(**REDIS_CONFIG)
# 初始化NEO4J驱动对象
_driver = GraphDatabase.driver(**NEO4J_CONFIG)

#-------------------------------------------第二步: 完成查询neo4j数据库的函数----------------------------------#

def query_neo4j(text):
    """
    description: 根据用户对话文本中的可能存在的症状查询图数据库.
    :param text: 用户的输入文本.
    :return: 用户描述的症状对应的疾病列表.
    """
    # 开启一个session操作图数据库
    with _driver.session() as session:
        """ 
        1.第一种方式:直接NEO4J查询语句查询
            NEO4J中存储的为一个个的“疾病名/症状名”名词单词,而查询条件的输入的字符串可以为一句文本,
            也可以为一个“疾病名/症状名”名词单词,底层会通过KMP算法(一种改进的字符串匹配算法)进行查询条件的字符串匹配查询。
            正是因为使用了KMP算法,即使查询条件的字符串是一句文本,不是一个“疾病名/症状名”名词单词,
            也能通过字符串模糊匹配的方式间接抽取出句子中关键的“疾病名/症状名”名词单词进行查询。
        2.第二种方式:
            可以先通过训练好的命名实体模型对查询条件的一句文本字符串进行抽取出关键的“疾病名/症状名”名词单词,
            然后再进行NEO4J查询语句查询,虽然该方式精准度查询更好,但是要求该命名实体模型准确率要比较高,并且预测速度要快,
            才能保证整个系统的实时性。
        """
         # cypher语句, 匹配句子中存在的所有症状节点,
         # 保存这些节点并逐一通过关系dis_to_sym进行对应病症的查找, 返回找到的疾病名字列表.
        cypher = "MATCH(a:Symptom) WHERE(%r contains a.name) WITH a MATCH(a)-[r:dis_to_sym]-(b:Disease) RETURN b.name LIMIT 5" %text
        # 运行这条cypher语句
        record = session.run(cypher)
        # 从record对象中获得结果列表
        result = list(map(lambda x: x[0], record))
    return result

if __name__ == "__main__":
    text = "我最近腹痛!"
    result = query_neo4j(text)
    print("疾病列表:", result)

8.1 任务介绍与模型选用

  • 学习目标:
    • 了解句子主题相关任务的相关知识.
    • 了解选用的模型及其原因.

  • 句子主题相关任务:
    • 在多轮对话系统中, 往往需要判断用户的最近两次回复是否围绕同一主题, 来决定问答机器人是否也根据自己上一次的回复来讨论相关内容. 在线医生问答过程中, 同样需要这样的处理, 确保用户一直讨论疾病有关的内容, 来根据症状推断病情. 这种任务的形式与判断两个句子是否连贯的形式相同, 他们都需要输入两段文本内容, 返回'是'或'否'的二分类标签.

  • 选用的模型及其原因:
    • 对话系统是开放的语言处理系统, 可能出现各种文字, 当我们的训练集有限无法覆盖大多数情况时, 可以直接使用预训练模型进行文字表示. 我们这里使用了bert-chinese预训练模型, 同时为了适应我们研究的垂直领域, 我们在后面自定义浅层的微调模型, 它将由两层全连接网络组成, 之后我们会详细介绍.

8.2 训练数据集

  • 学习目标:
    • 了解训练数据集的样式及其相关解释.
    • 了解训练数据集的来源和扩充方式.

  • 训练数据集的样式:
1       腹股沟淋巴结肿大腹股沟皮下包块  想请您帮忙解读一下上面的b超结果,是否要治疗,或做进一步的检查?>因为做完b超医生下班了
1       想请您帮忙解读一下上面的b超结果,是否要治疗,或做进一步的检查?因为做完b超医生下班了    左侧的包
块是否是普通的淋巴结肿大?
1       左侧的包块是否是普通的淋巴结肿大?      按压不疼,但用手敲会有点刺痛
1       按压不疼,但用手敲会有点刺痛    ?
1       抗谬肋氏管激素偏低抗缪肋氏管激素偏低    昨天同房后出血了,以前都不会,先是鲜红色,今天变褐色,少
量,不想去医院检查,过几天它会自己停吧?还是要吃什么药?
0       水痘水痘后第七天脸上色素严重    五险一金会下调吗
0       腺样体重度肥大,分泌性中耳炎宝宝腺样体肥大怎么办        我爸因车祸死亡意外险能赔偿吗
0       尿血尿血这种情况要求高不高治疗  车辆保险理赔回执弄丢了可以补吗
0       尿路感染尿路感染备孕中  在单位辞职了,当时没办医保,是否能申办居民医保?
0       眼角有血块左眼角有血块状        有谁知道,安*长*树出险了需要提供哪些医院证明?

  • 数据集的相关解释:
    • 数据集中的第一列代表标签, 1为正标签, 代表后面的两句话是在讨论同一主题. 0为负标签, 代表后面的两句话不相关.
    • 数据集中的第二列是用户回复的文本信息, 第三列是与上一句相关或不相关的文本.
    • 正负样本的比例是1:1左右
  • 数据集来源及其扩充方式:
    • 来源: 正样本数据来自网络医患在线问答的真实语料. 负样本来自其他使用其他问答语料的回复信息, 保证两段文本不相关.
    • 扩充方式: 根据来源, 可通过数据抓取技术对语料集进行扩充.

8.3 BERT中文预训练模型

  • 学习目标:
    • 了解BERT中文预训练模型的有关知识和作用.
    • 掌握使用BERT中文预训练模型对句子编码的过程.

  • BERT中文预训练模型:
    • BERT模型整体架构基于Transformer模型架构, BERT中文预训练模型的解码器和编码器具有12层, 输出层中的线性层具有768个节点, 即输出张量最后一维的维度是768. 它使用的多头注意力机制结构中, 头的数量为12, 模型总参数量为110M. 同时, 它在中文简体和繁体上进行训练, 因此适合中文简体和繁体任务.

  • BERT中文预训练模型作用:
    • 在实际的文本任务处理中, 有些训练语料很难获得, 他们的总体数量和包含的词汇总数都非常少, 不适合用于训练带有Embedding层的模型, 但这些数据中却又蕴含这一些有价值的规律可以被模型挖掘, 在这种情况下,使用预训练模型对原始文本进行编码是非常不错的选择, 因为预训练模型来自大型语料, 能够使得当前文本具有意义, 虽然这些意义可能并不针对某个特定领域, 但是这种缺陷可以使用微调模型来进行弥补.

  • 使用BERT中文预训练模型对两个句子进行编码:
import torch
import torch.nn as nn


# 使用torch.hub加载bert中文模型的字映射器
tokenizer = torch.hub.load('huggingface/pytorch-transformers', 'tokenizer', 'bert-base-chinese')
# 使用torch.hub加载bert中文模型
model =  torch.hub.load('huggingface/pytorch-transformers', 'model', 'bert-base-chinese')

def get_bert_encode(text_1, text_2, mark=102, max_len=10):
    """
    description: 使用bert中文模型对输入的文本对进行编码
    :param text_1: 代表输入的第一句话
    :param text_2: 代表输入的第二句话
    :param mark: 分隔标记, 是预训练模型tokenizer本身的标记符号, 当输入是两个文本时,
                 得到的index_tokens会以102进行分隔
    :param max_len: 文本的允许最大长度, 也是文本的规范长度即大于该长度要被截断, 小于该长度要进行0补齐
    :return 输入文本的bert编码
    """
    # 使用tokenizer的encode方法对输入的两句文本进行字映射.
    indexed_tokens = tokenizer.encode(text_1, text_2)
    # 准备对映射后的文本进行规范长度处理即大于该长度要被截断, 小于该长度要进行0补齐
    # 所以需要先找到分隔标记的索引位置
    k = indexed_tokens.index(mark)
    # 首先对第一句话进行长度规范因此将indexed_tokens截取到[:k]判断
    if len(indexed_tokens[:k]) >= max_len:
        # 如果大于max_len, 则进行截断
        indexed_tokens_1 = indexed_tokens[:max_len]
    else:
        # 否则使用[0]进行补齐, 补齐的0的个数就是max_len-len(indexed_tokens[:k])
        indexed_tokens_1 = indexed_tokens[:k] + (max_len-len(indexed_tokens[:k]))*[0]

    # 同理下面是对第二句话进行规范长度处理, 因此截取[k:]
    if len(indexed_tokens[k:]) >= max_len:
        # 如果大于max_len, 则进行截断
        indexed_tokens_2 = indexed_tokens[k:k+max_len]
    else:
         # 否则使用[0]进行补齐, 补齐的0的个数就是max_len-len(indexed_tokens[:k])
        indexed_tokens_2 = indexed_tokens[k:] + (max_len-len(indexed_tokens[k:]))*[0]

    # 最后将处理后的indexed_tokens_1和indexed_tokens_2再进行相加
    indexed_tokens = indexed_tokens_1 + indexed_tokens_2
    # 为了让模型在编码时能够更好的区分这两句话, 我们可以使用分隔ids,
    # 它是一个与indexed_tokens等长的向量, 0元素的位置代表是第一句话
    # 1元素的位置代表是第二句话, 长度都是max_len
    segments_ids = [0]*max_len + [1]*max_len
    # 将segments_ids和indexed_tokens转换成模型需要的张量形式
    segments_tensor = torch.tensor([segments_ids])
    tokens_tensor = torch.tensor([indexed_tokens])
    # 模型不自动求解梯度
    with torch.no_grad():
        # 使用bert model进行编码, 传入参数tokens_tensor和segments_tensor得到encoded_layers
        encoded_layers, _ = model(tokens_tensor, token_type_ids=segments_tensor)
    return encoded_layers
  • 输入参数:
text_1 = "人生该如何起头"
text_2 = "改变要如何起手"

  • 调用:
encoded_layers = get_bert_encode(text_1, text_2)
print(encoded_layers)
print(encoded_layers.shape)

  • 输出效果:
tensor([[[ 1.0210,  0.0659, -0.3472,  ...,  0.5131, -0.7699,  0.0202],
         [-0.1966,  0.2660,  0.3689,  ..., -0.0650, -0.2853, -0.1777],
         [ 0.9295, -0.3890, -0.1026,  ...,  1.3917,  0.4692, -0.0851],
         ...,
         [ 1.4777,  0.7781, -0.4310,  ...,  0.7403,  0.2006, -0.1198],
         [ 0.3867, -0.2031, -0.0721,  ...,  1.0050, -0.2479, -0.3525],
         [ 0.0599,  0.2883, -0.4011,  ..., -0.1875, -0.2546,  0.0453]]])

torch.Size([1, 20, 768])

  • 小节总结:
    • 学习了BERT中文预训练模型的有关知识和作用.
    • 使用BERT中文预训练模型对句子编码的函数: get_bert_encode

8.4 微调模型

  • 学习目标:
    • 了解微调模型的作用.
    • 掌握构建全连接微调模型的过程.

  • 微调模型的作用:
    • 微调模型一般用在迁移学习中的预训练模型之后, 因为单纯的预训练模型往往不能针对特定领域或任务获得预期结果, 需要通过微调模型在特定领域或任务上调节整体模型功能, 使其适应当下问题.

  • 构建全连接微调模型的代码分析:
import torch
import torch.nn as nn
import torch.nn.functional as F


class Net(nn.Module):
    """定义微调网络的类"""
    def __init__(self, char_size=20, embedding_size=768, dropout=0.2):
        """
        :param char_size: 输入句子中的字符数量, 因为规范后每条句子长度是max_len, 因此char_size为2*max_len
        :param embedding_size: 字嵌入的维度, 因为使用的bert中文模型嵌入维度是768, 因此embedding_size为768
        :param dropout: 为了防止过拟合, 网络中将引入Dropout层, dropout为置0比率, 默认是0.2
        """
        super(Net, self).__init__()
        # 将char_size和embedding_size传入其中
        self.char_size = char_size
        self.embedding_size = embedding_size
        # 实例化化必要的层和层参数:
        # 实例化Dropout层
        self.dropout = nn.Dropout(p=dropout)
        # 实例化第一个全连接层
        self.fc1 = nn.Linear(char_size*embedding_size, 8)
        # 实例化第二个全连接层
        self.fc2 = nn.Linear(8, 2)

    def forward(self, x):
        # 对输入的张量形状进行变换, 以满足接下来层的输入要求
        x = x.view(-1, self.char_size*self.embedding_size)
        # 使用dropout层
        x = self.dropout(x)
        # 使用第一个全连接层并使用relu函数
        x = F.relu(self.fc1(x))
        # 使用dropout层
        x = self.dropout(x)
        # 使用第二个全连接层并使用relu函数
        x = F.relu(self.fc2(x))
        return x
  • 实例化参数:
embedding_size = 768
char_size = 20
dropout = 0.2

  • 输入参数:
x = torch.randn(1, 20, 768)

  • 调用:
net = Net(char_size, embedding_size, dropout)
nr = net(x)
print(nr)
  • 输出效果:
tensor([[0.0000, 0.4061]], grad_fn=<ReluBackward0>)

  • 小节总结:

    • 学习了微调模型的作用:
      • 微调模型一般用在迁移学习中的预训练模型之后, 因为单纯的预训练模型往往不能针对特定领域或任务获得预期结果, 需要通过微调模型在特定领域或任务上调节整体模型功能, 使其适应当下问题.

    • 学习并实现了构建全连接微调模型的类: class Net(nn.Module)

8.5 进行模型训练

  • 学习目标:
    • 了解进行模型训练的步骤.
    • 掌握模型训练中每个步骤的实现过程.

  • 进行模型训练的步骤:
    • 第一步: 构建数据加载器函数.
    • 第二步: 构建模型训练函数.
    • 第三步: 构建模型验证函数.
    • 第四步: 调用训练和验证函数并打印日志.
    • 第五步: 绘制训练和验证的损失和准确率对照曲线.
    • 第六步: 模型保存.

  • 第一步: 构建数据加载器函数
import pandas as pd
from sklearn.utils import shuffle
from functools import reduce
from collections import Counter
from bert_chinese_encode import get_bert_encode
import torch
import torch.nn as nn


def data_loader(data_path, batch_size, split=0.2):
    """
    description: 从持久化文件中加载数据, 并划分训练集和验证集及其批次大小
    :param data_path: 训练数据的持久化路径
    :param batch_size: 训练和验证数据集的批次大小
    :param split: 训练集与验证的划分比例
    :return: 训练数据生成器, 验证数据生成器, 训练数据数量, 验证数据数量
    """
    # 使用pd进行csv数据的读取
    data = pd.read_csv(data_path, header=None, sep="\t")

    # 打印整体数据集上的正负样本数量
    print("数据集的正负样本数量:")
    print(dict(Counter(data[0].values)))
    # 打乱数据集的顺序
    data = shuffle(data).reset_index(drop=True)
    # 划分训练集和验证集
    split_point = int(len(data)*split)
    valid_data = data[:split_point]
    train_data = data[split_point:]

    # 验证数据集中的数据总数至少能够满足一个批次
    if len(valid_data) < batch_size:
        raise("Batch size or split not match!")


    def _loader_generator(data):
        """
        description: 获得训练集/验证集的每个批次数据的生成器
        :param data: 训练数据或验证数据
        :return: 一个批次的训练数据或验证数据的生成器
        """
        # 以每个批次的间隔遍历数据集
        for batch in range(0, len(data), batch_size):
            # 预定于batch数据的张量列表
            batch_encoded = []
            batch_labels = []
            # 将一个bitch_size大小的数据转换成列表形式,[[label, text_1, text_2]]
            # 并进行逐条遍历
            for item in data[batch: batch+batch_size].values.tolist():
                # 每条数据中都包含两句话, 使用bert中文模型进行编码
                encoded = get_bert_encode(item[1], item[2])
                # 将编码后的每条数据装进预先定义好的列表中
                batch_encoded.append(encoded)
                # 同样将对应的该batch的标签装进labels列表中
                batch_labels.append([item[0]])
            # 使用reduce高阶函数将列表中的数据转换成模型需要的张量形式
            # encoded的形状是(batch_size, 2*max_len, embedding_size)
            encoded = reduce(lambda x, y : torch.cat((x, y), dim=0), batch_encoded)
            labels = torch.tensor(reduce(lambda x, y : x + y, batch_labels))
            # 以生成器的方式返回数据和标签
            yield (encoded, labels)

    # 对训练集和验证集分别使用_loader_generator函数, 返回对应的生成器
    # 最后还要返回训练集和验证集的样本数量
    return _loader_generator(train_data), _loader_generator(valid_data), len(train_data), len(valid_data)
  • 输入参数:
# 数据所在路径
data_path = "./train_data.csv"
# 定义batch_size大小
batch_size = 32

  • 调用:
train_data_labels, valid_data_labels, \
train_data_len, valid_data_len = data_loader(data_path, batch_size)

print(next(train_data_labels))
print(next(valid_data_labels))
print("train_data_len:", train_data_len)
print("valid_data_len:", valid_data_len)

  • 输出效果:
(tensor([[[-0.7295,  0.8199,  0.8320,  ...,  0.0933,  1.2171,  0.4833],
         [ 0.8707,  1.0131, -0.2556,  ...,  0.2179, -1.0671,  0.1946],
         [ 0.0344, -0.5605, -0.5658,  ...,  1.0855, -0.9122,  0.0222]]], tensor([0, 0, 1, 1, 1, 1, 0, 1, 0, 0, ..., 1, 0, 1, 0, 1, 1, 1, 1]))


(tensor([[[-0.5263, -0.3897, -0.5725,  ...,  0.5523, -0.2289, -0.8796],
         [ 0.0468, -0.5291, -0.0247,  ...,  0.4221, -0.2501, -0.0796],
         [-0.2133, -0.5552, -0.0584,  ..., -0.8031,  0.1753, -0.3476]]]), tensor([0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, ..., 0, 0, 1, 0, 1,1, 1]))

train_data_len: 22186 
valid_data_len: 5546

  • 第二步: 构建模型训练函数
# 加载微调网络
from finetuning_net import Net
import torch.optim as optim


# 定义embedding_size, char_size
embedding_size = 768
char_size = 2 * max_len
# 实例化微调网络
net = Net(embedding_size, char_size)
# 定义交叉熵损失函数
criterion = nn.CrossEntropyLoss()
# 定义SGD优化方法
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)


def train(train_data_labels):
    """
    description: 训练函数, 在这个过程中将更新模型参数, 并收集准确率和损失
    :param train_data_labels: 训练数据和标签的生成器对象
    :return: 整个训练过程的平均损失之和以及正确标签的累加数
    """
    # 定义训练过程的初始损失和准确率累加数
    train_running_loss = 0.0
    train_running_acc = 0.0
    # 循环遍历训练数据和标签生成器, 每个批次更新一次模型参数
    for train_tensor, train_labels in train_data_labels:
        # 初始化该批次的优化器
        optimizer.zero_grad()
        # 使用微调网络获得输出
        train_outputs = net(train_tensor)
        # 得到该批次下的平均损失
        train_loss = criterion(train_outputs, train_labels)
        # 将该批次的平均损失加到train_running_loss中
        train_running_loss += train_loss.item()
        # 损失反向传播
        train_loss.backward()
        # 优化器更新模型参数
        optimizer.step()
        # 将该批次中正确的标签数量进行累加, 以便之后计算准确率
        train_running_acc += (train_outputs.argmax(1) == train_labels).sum().item()
    return train_running_loss, train_running_acc
  • 第三步: 模型验证函数
def valid(valid_data_labels):
    """
    description: 验证函数, 在这个过程中将验证模型的在新数据集上的标签, 收集损失和准确率
    :param valid_data_labels: 验证数据和标签的生成器对象
    :return: 整个验证过程的平均损失之和以及正确标签的累加数
    """
    # 定义训练过程的初始损失和准确率累加数
    valid_running_loss = 0.0
    valid_running_acc = 0.0
    # 循环遍历验证数据和标签生成器
    for valid_tensor, valid_labels in valid_data_labels:
        # 不自动更新梯度
        with torch.no_grad():
            # 使用微调网络获得输出
            valid_outputs = net(valid_tensor)
            # 得到该批次下的平均损失
            valid_loss = criterion(valid_outputs, valid_labels)
            # 将该批次的平均损失加到valid_running_loss中
            valid_running_loss += valid_loss.item()
            # 将该批次中正确的标签数量进行累加, 以便之后计算准确率
            valid_running_acc += (valid_outputs.argmax(1) == valid_labels).sum().item()
    return valid_running_loss,  valid_running_acc
  • 第四步: 调用训练和验证函数并打印日志
# 定义训练轮数
epochs = 20

# 定义盛装每轮次的损失和准确率列表, 用于制图
all_train_losses = []
all_valid_losses = []
all_train_acc = []
all_valid_acc = []

# 进行指定轮次的训练
for epoch in range(epochs):
    # 打印轮次
    print("Epoch:", epoch + 1)
    # 通过数据加载器获得训练数据和验证数据生成器, 以及对应的样本数量
    train_data_labels, valid_data_labels, train_data_len, valid_data_len = data_loader(data_path, batch_size)
    # 调用训练函数进行训练
    train_running_loss, train_running_acc = train(train_data_labels)
    # 调用验证函数进行验证
    valid_running_loss, valid_running_acc = valid(valid_data_labels)
    # 计算每一轮的平均损失, train_running_loss和valid_running_loss是每个批次的平均损失之和
    # 因此将它们乘以batch_size就得到了该轮的总损失, 除以样本数即该轮次的平均损失
    train_average_loss = train_running_loss * batch_size / train_data_len
    valid_average_loss = valid_running_loss * batch_size / valid_data_len

    # train_running_acc和valid_running_acc是每个批次的正确标签累加和,
    # 因此只需除以对应样本总数即是该轮次的准确率
    train_average_acc = train_running_acc /  train_data_len
    valid_average_acc = valid_running_acc / valid_data_len
    # 将该轮次的损失和准确率装进全局损失和准确率列表中, 以便制图
    all_train_losses.append(train_average_loss)
    all_valid_losses.append(valid_average_loss)
    all_train_acc.append(train_average_acc)
    all_valid_acc.append(valid_average_acc)
    # 打印该轮次下的训练损失和准确率以及验证损失和准确率
    print("Train Loss:", train_average_loss, "|", "Train Acc:", train_average_acc)
    print("Valid Loss:", valid_average_loss, "|", "Valid Acc:", valid_average_acc)


print('Finished Training')

  • 输出效果:
Epoch: 1
Train Loss: 0.693169563147374 | Train Acc: 0.5084898843930635
Valid Loss: 0.6931480603018824 | Valid Acc: 0.5042777377521613
{1: 14015, 0: 13720}
Epoch: 2
Train Loss: 0.6931440165277162 | Train Acc: 0.514992774566474
Valid Loss: 0.6931474804019379 | Valid Acc: 0.5026567002881844
{1: 14015, 0: 13720}
Epoch: 3
Train Loss: 0.6931516138804441 | Train Acc: 0.5
Valid Loss: 0.69314516217633 | Valid Acc: 0.5065291786743515
{1: 14015, 0: 13720}
Epoch: 4
Train Loss: 0.6931474804878235 | Train Acc: 0.5065028901734104
Valid Loss: 0.6931472256650842 | Valid Acc: 0.5052233429394812
{1: 14015, 0: 13720}
Epoch: 5
Train Loss: 0.6931474804878235 | Train Acc: 0.5034320809248555
Valid Loss: 0.6931475739314165 | Valid Acc: 0.5055385446685879
{1: 14015, 0: 13720}
Epoch: 6
Train Loss: 0.6931492934337241 | Train Acc: 0.5126445086705202
Valid Loss: 0.6931462547277512 | Valid Acc: 0.5033771613832853
{1: 14015, 0: 13720}
Epoch: 7
Train Loss: 0.6931459204309938 | Train Acc: 0.5095736994219653
Valid Loss: 0.6931174922229921 | Valid Acc: 0.5065742074927954
{1: 14015, 0: 13720}
Epoch: 8
Train Loss: 0.5545259035391614 | Train Acc: 0.759393063583815
Valid Loss: 0.4199462383770805 | Valid Acc: 0.9335374639769453
{1: 14015, 0: 13720}
Epoch: 9
Train Loss: 0.4011955714294676 | Train Acc: 0.953757225433526
Valid Loss: 0.3964169790877045 | Valid Acc: 0.9521793948126801
{1: 14015, 0: 13720}
Epoch: 10
Train Loss: 0.3893018603497158 | Train Acc: 0.9669436416184971
Valid Loss: 0.3928600374491139 | Valid Acc: 0.9525846541786743
{1: 14015, 0: 13720}
Epoch: 11
Train Loss: 0.3857506763383832 | Train Acc: 0.9741690751445087
Valid Loss: 0.38195425426582097 | Valid Acc: 0.9775306195965417
{1: 14015, 0: 13720}
Epoch: 12
Train Loss: 0.38368317760484066 | Train Acc: 0.9772398843930635
Valid Loss: 0.37680484129046155 | Valid Acc: 0.9780259365994236
{1: 14015, 0: 13720}
Epoch: 13
Train Loss: 0.37407022137517876 | Train Acc: 0.9783236994219653
Valid Loss: 0.3750278927192564 | Valid Acc: 0.9792867435158501
{1: 14015, 0: 13720}
Epoch: 14
Train Loss: 0.3707401707682306 | Train Acc: 0.9801300578034682
Valid Loss: 0.37273150721097886 | Valid Acc: 0.9831592219020173
{1: 14015, 0: 13720}
Epoch: 15
Train Loss: 0.37279492521906177 | Train Acc: 0.9817557803468208
Valid Loss: 0.3706809586123362 | Valid Acc: 0.9804574927953891
{1: 14015, 0: 13720}
Epoch: 16
Train Loss: 0.37660940017314315 | Train Acc: 0.9841040462427746
Valid Loss: 0.3688154769390392 | Valid Acc: 0.984600144092219
{1: 14015, 0: 13720}
Epoch: 17
Train Loss: 0.3749892661681754 | Train Acc: 0.9841040462427746
Valid Loss: 0.3688570175760074 | Valid Acc: 0.9817633285302594
{1: 14015, 0: 13720}
Epoch: 18
Train Loss: 0.37156562515765945 | Train Acc: 0.9826589595375722
Valid Loss: 0.36880484627028365 | Valid Acc: 0.9853656340057637
{1: 14015, 0: 13720}
Epoch: 19
Train Loss: 0.3674713007976554 | Train Acc: 0.9830202312138728
Valid Loss: 0.366314563545954 | Valid Acc: 0.9850954610951008
{1: 14015, 0: 13720}
Epoch: 20
Train Loss: 0.36878046806837095 | Train Acc: 0.9842846820809249
Valid Loss: 0.367835852100114 | Valid Acc: 0.9793317723342939
Finished Training

  • 第五步: 绘制训练和验证的损失和准确率对照曲线
# 导入制图工具包
import matplotlib.pyplot as plt
from matplotlib.pyplot import MultipleLocator


# 创建第一张画布
plt.figure(0)

# 绘制训练损失曲线
plt.plot(all_train_losses, label="Train Loss")
# 绘制验证损失曲线, 颜色为红色
plt.plot(all_valid_losses, color="red", label="Valid Loss")
# 定义横坐标刻度间隔对象, 间隔为1, 代表每一轮次
x_major_locator=MultipleLocator(1)
# 获得当前坐标图句柄
ax=plt.gca()
# 设置横坐标刻度间隔
ax.xaxis.set_major_locator(x_major_locator)
# 设置横坐标取值范围
plt.xlim(1,epochs)
# 曲线说明在左上方
plt.legend(loc='upper left')
# 保存图片
plt.savefig("./loss.png")



# 创建第二张画布
plt.figure(1)

# 绘制训练准确率曲线
plt.plot(all_train_acc, label="Train Acc")

# 绘制验证准确率曲线, 颜色为红色
plt.plot(all_valid_acc, color="red", label="Valid Acc")
# 定义横坐标刻度间隔对象, 间隔为1, 代表每一轮次
x_major_locator=MultipleLocator(1)
# 获得当前坐标图句柄
ax=plt.gca()
# 设置横坐标刻度间隔
ax.xaxis.set_major_locator(x_major_locator)
# 设置横坐标取值范围
plt.xlim(1,epochs)
# 曲线说明在左上方
plt.legend(loc='upper left')
# 保存图片
plt.savefig("./acc.png")
  • 训练和验证损失对照曲线:

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 训练和验证准确率对照曲线:

在线部分:werobot服务、主要逻辑服务、句子相关模型服务、BERT中文预训练模型+微调模型(目的:比较两句话text1和text2之间是否有关联)、模型在Flask部署

  • 分析:
    • 根据损失对照曲线, 微调模型在第6轮左右开始掌握数据规律迅速下降, 说明模型能够从数据中获取语料特征,正在收敛. 根据准确率对照曲线中验证准确率在第10轮左右区域稳定,最终维持在98%左右.

  • 第六步: 模型保存
# 模型保存时间
time_ = int(time.time())
# 保存路径
MODEL_PATH = './model/BERT_net_%d.pth' % time_
# 保存模型参数
torch.save(net.state_dict(), MODEL_PATH)
  • 输出效果:
    • 在/data/bert_serve/路径下生成BERT_net_ + 时间戳.pth的文件.

  • 小节总结:
    • 学习了进行模型训练的步骤:
      • 第一步: 构建数据加载器函数.
      • 第二步: 构建模型训练函数.
      • 第三步: 构建模型验证函数.
      • 第四步: 调用训练和验证函数并打印日志.
      • 第五步: 绘制训练和验证的损失和准确率对照曲线.
      • 第六步: 模型保存.

8.6 模型部署

  • 学习目标:
    • 掌握使用Flask框架进行模型部署的实现过程.

  • 使用Flask框架进行模型部署的步骤:
    • 第一步: 部署模型预测代码.
    • 第二步: 以挂起的方式启动服务.
    • 第三步: 进行测试.

  • 第一步: 部署模型预测代码
from flask import Flask
from flask import request
app = Flask(__name__)


import torch
# 导入中文预训练模型编码函数
from bert_chinese_encode import get_bert_encode
# 导入微调网络
from finetuning_net import Net

# 导入训练好的模型
MODEL_PATH = "./model/BERT_net.pth"
# 定义实例化模型参数
embedding_size = 768
char_size = 20
dropout = 0.2

# 初始化微调网络模型
net = Net(embedding_size, char_size, dropout)
# 加载模型参数
net.load_state_dict(torch.load(MODEL_PATH))
# 使用评估模式
net.eval()

# 定义服务请求路径和方式
@app.route('/v1/recognition/', methods=["POST"])
def recognition():
    # 接收数据
    text_1 = request.form['text1']
    text_2 = request.form['text2']
    # 对原始文本进行编码
    inputs = get_bert_encode(text_1, text_2, mark=102, max_len=10)
    # 使用微调模型进行预测
    outputs = net(inputs)
    # 获得预测结果
    _, predicted = torch.max(outputs, 1)
    # 返回字符串类型的结果
    return str(predicted.item())
  • 第二步: 启动服务
gunicorn -w 1 -b 0.0.0.0:5001 app:app 

  • 第三步: 进行测试
  • 测试脚本:
import requests

url = "http://0.0.0.0:5001/v1/recognition/"
data = {"text1":"人生该如何起头", "text2": "改变要如何起手"}
res = requests.post(url, data=data)

print("预测样本:", data["text_1"], "|", data["text_2"])
print("预测结果:", res.text)
  • 运行脚本:
python test.py
  • 输出效果:
预测样本: 人生该如何起头 | 改变要如何起手
预测结果: 1

  • 小节总结:
    • 学习了使用Flask框架进行模型部署的实现过程:
      • 第一步: 部署模型预测代码.
      • 第二步: 以挂起的方式启动服务.
      • 第三步: 进行测试.


import torch
import torch.nn as nn
from tqdm import tqdm

device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("device",device)
"""
如果每次运行都重新下载“bert-base-chinese”的话,执行如下操作
    window:cd C:/Users/Administrator/.cache/torch/hub/huggingface_pytorch-transformers_master
    linux:cd /root/.cache/torch/hub/huggingface_pytorch-transformers_master
    activate pytorch
    pip install .
"""

# 使用torch.hub加载bert中文模型
model = torch.hub.load('huggingface/pytorch-transformers', 'model', 'bert-base-chinese').to(device)
# 使用torch.hub加载bert中文模型的字映射器
tokenizer = torch.hub.load('huggingface/pytorch-transformers', 'tokenizer', 'bert-base-chinese')

def get_bert_encode(text_1, text_2, mark=102, max_len=10):
    """
    description: 使用bert中文模型对输入的文本对进行编码
    :param text_1: 代表输入的第一句话
    :param text_2: 代表输入的第二句话
    :param mark: 分隔标记, 是预训练模型tokenizer本身的标记符号, 当输入是两个文本时,
                 得到的index_tokens会以102进行分隔
    :param max_len: 文本的允许最大长度, 也是文本的规范长度即大于该长度要被截断, 小于该长度要进行0补齐
    :return 输入文本的bert编码
    """
    # 使用tokenizer的encode方法对输入的两句文本进行字映射.
    indexed_tokens = tokenizer.encode(text_1, text_2)
    # 准备对映射后的文本进行规范长度处理即大于该长度要被截断, 小于该长度要进行0补齐
    # 所以需要先找到分隔标记的索引位置
    k = indexed_tokens.index(mark)
    # 首先对第一句话进行长度规范因此将indexed_tokens截取到[:k]判断
    if len(indexed_tokens[:k]) >= max_len:
        # 如果大于max_len, 则进行截断
        indexed_tokens_1 = indexed_tokens[:max_len]
    else:
        # 否则使用[0]进行补齐, 补齐的0的个数就是max_len-len(indexed_tokens[:k])
        indexed_tokens_1 = indexed_tokens[:k] + (max_len-len(indexed_tokens[:k]))*[0]

    # 同理下面是对第二句话进行规范长度处理, 因此截取[k:]
    if len(indexed_tokens[k:]) >= max_len:
        # 如果大于max_len, 则进行截断
        indexed_tokens_2 = indexed_tokens[k:k+max_len]
    else:
         # 否则使用[0]进行补齐, 补齐的0的个数就是max_len-len(indexed_tokens[:k])
        indexed_tokens_2 = indexed_tokens[k:] + (max_len-len(indexed_tokens[k:]))*[0]

    # 最后将处理后的indexed_tokens_1和indexed_tokens_2再进行相加
    indexed_tokens = indexed_tokens_1 + indexed_tokens_2
    # 为了让模型在编码时能够更好的区分这两句话, 我们可以使用分隔ids,
    # 它是一个与indexed_tokens等长的向量, 0元素的位置代表是第一句话
    # 1元素的位置代表是第二句话, 长度都是max_len
    segments_ids = [0]*max_len + [1]*max_len
    # 将segments_ids和indexed_tokens转换成模型需要的张量形式
    segments_tensor = torch.tensor([segments_ids]).to(device)
    tokens_tensor = torch.tensor([indexed_tokens]).to(device)

    # 模型不自动求解梯度
    with torch.no_grad():
        """
        BERT中文预训练模型之所以能传入两句话(text1和text2)进行比较两句话是否有相似性,
        因为BERT中加入了两个新特性:
            Mask language model(有掩码遮挡的语言模型)
            text1和text2(两句文本比较是否有语义相关性/相似性)
        """
        # 使用bert model进行编码, 传入参数tokens_tensor和segments_tensor得到encoded_layers
        encoded_layers, _ = model(tokens_tensor, token_type_ids=segments_tensor)
    return encoded_layers

# # 输入参数:
# text_1 = "人生该如何起头"
# text_2 = "改变要如何起手"
# # 调用:
# encoded_layers = get_bert_encode(text_1, text_2)
# print(encoded_layers)
# print(encoded_layers.shape)


import torch
import torch.nn as nn
import torch.nn.functional as F

class Net(nn.Module):
    """定义微调网络的类"""
    def __init__(self, char_size=20, embedding_size=768, dropout=0.2):
        """
        :param char_size: 输入句子中的字符数量, 因为规范后每条句子长度是max_len, 因此char_size为2*max_len
        :param embedding_size: 字嵌入的维度, 因为使用的bert中文模型嵌入维度是768, 因此embedding_size为768
        :param dropout: 为了防止过拟合, 网络中将引入Dropout层, dropout为置0比率, 默认是0.2
        """
        super(Net, self).__init__()
        # 将char_size和embedding_size传入其中
        self.char_size = char_size
        self.embedding_size = embedding_size
        # 实例化化必要的层和层参数:
        # 实例化Dropout层
        self.dropout = nn.Dropout(p=dropout)
        # 实例化第一个全连接层
        self.fc1 = nn.Linear(char_size*embedding_size, 8)
        # 实例化第二个全连接层
        self.fc2 = nn.Linear(8, 2)

    def forward(self, x):
        # 对输入的张量形状进行变换, 以满足接下来层的输入要求
        x = x.view(-1, self.char_size*self.embedding_size)
        # 使用dropout层
        x = self.dropout(x)
        # 使用第一个全连接层并使用relu函数
        x = F.relu(self.fc1(x))
        # 使用dropout层
        x = self.dropout(x)
        # 使用第二个全连接层并使用relu函数
        x = F.relu(self.fc2(x))
        return x

# # 实例化参数:
# embedding_size = 768
# char_size = 20
# dropout = 0.2
# # 输入参数:
# x = torch.randn(1, 20, 768)
# # 调用:
# net = Net(char_size, embedding_size, dropout)
# nr = net(x)
# print(nr)


"""
进行模型训练的步骤:
    第一步: 构建数据加载器函数.
    第二步: 构建模型训练函数.
    第三步: 构建模型验证函数.
    第四步: 调用训练和验证函数并打印日志.
    第五步: 绘制训练和验证的损失和准确率对照曲线.
    第六步: 模型保存.
"""

#----------------------------------------第一步: 构建数据加载器函数-----------------------------------------#
import pandas as pd
from sklearn.utils import shuffle
from functools import reduce
from collections import Counter
import torch
import torch.nn as nn


def data_loader(data_path, batch_size, split=0.2):
    """
    description: 从持久化文件中加载数据, 并划分训练集和验证集及其批次大小
    :param data_path: 训练数据的持久化路径
    :param batch_size: 训练和验证数据集的批次大小
    :param split: 训练集与验证的划分比例
    :return: 训练数据生成器, 验证数据生成器, 训练数据数量, 验证数据数量
    """
    # 使用pd进行csv数据的读取
    data = pd.read_csv(data_path, header=None, sep="\t")

    # 打印整体数据集上的正负样本数量
    print("数据集的正负样本数量:")
    print(dict(Counter(data[0].values)))
    # 打乱数据集的顺序
    # reset_index(drop=True):因为数据顺序打乱了,因此需要把对应的数据索引也重置
    data = shuffle(data).reset_index(drop=True)
    # 划分训练集和验证集
    split_point = int(len(data)*split)
    valid_data = data[:split_point]
    train_data = data[split_point:]

    # 验证数据集中的数据总数至少能够满足一个批次
    if len(valid_data) < batch_size:
        raise("Batch size or split not match!")

    def _loader_generator(data):
        """
        description: 获得训练集/验证集的每个批次数据的生成器
        :param data: 训练数据或验证数据
        :return: 一个批次的训练数据或验证数据的生成器
        """
        # 以每个批次的间隔遍历数据集
        for batch in range(0, len(data), batch_size):
            # 预定于batch数据的张量列表
            batch_encoded = []
            batch_labels = []
            # 将一个bitch_size大小的数据转换成列表形式,[[label, text_1, text_2]]
            # 并进行逐条遍历
            for item in data[batch: batch+batch_size].values.tolist():
                # 每条数据中都包含两句话, 使用bert中文模型进行编码
                encoded = get_bert_encode(item[1], item[2])
                # 将编码后的每条数据装进预先定义好的列表中
                batch_encoded.append(encoded)
                # 同样将对应的该batch的标签装进labels列表中
                batch_labels.append([item[0]])
            # 使用reduce高阶函数将列表中的数据转换成模型需要的张量形式
            # encoded的形状是(batch_size, 2*max_len, embedding_size)
            encoded = reduce(lambda x, y : torch.cat((x, y), dim=0), batch_encoded)
            labels = torch.tensor(reduce(lambda x, y : x + y, batch_labels))
            # 以生成器的方式返回数据和标签
            yield (encoded.to(device), labels.to(device))

    # 对训练集和验证集分别使用_loader_generator函数, 返回对应的生成器
    # 最后还要返回训练集和验证集的样本数量
    return _loader_generator(train_data), _loader_generator(valid_data), len(train_data), len(valid_data)

# 输入参数:
# 数据所在路径
data_path = "./train_data.csv"
# 定义batch_size大小
batch_size = 32
max_len = 10

# 调用:
train_data_labels, valid_data_labels, train_data_len, valid_data_len = data_loader(data_path, batch_size)

# print(next(train_data_labels))
# print(next(valid_data_labels))
print("train_data_len:", train_data_len)
print("valid_data_len:", valid_data_len)

#----------------------------------------第二步: 构建模型训练函数-----------------------------------------#

# 加载微调网络
import torch.optim as optim

# 定义embedding_size, char_size
embedding_size = 768
char_size = 2 * max_len
# 实例化微调网络
net = Net(embedding_size, char_size).to(device)
# 定义交叉熵损失函数
criterion = nn.CrossEntropyLoss().to(device)
# 定义SGD优化方法
optimizer = optim.SGD(net.parameters(), lr=0.001, momentum=0.9)

def train(train_data_labels):
    """
    description: 训练函数, 在这个过程中将更新模型参数, 并收集准确率和损失
    :param train_data_labels: 训练数据和标签的生成器对象
    :return: 整个训练过程的平均损失之和以及正确标签的累加数
    """
    # 定义训练过程的初始损失和准确率累加数
    train_running_loss = 0.0
    train_running_acc = 0.0
    # 循环遍历训练数据和标签生成器, 每个批次更新一次模型参数
    # for train_tensor, train_labels in train_data_labels:
    for train_tensor, train_labels in tqdm(train_data_labels):
        # 初始化该批次的优化器
        optimizer.zero_grad()
        # 使用微调网络获得输出
        train_outputs = net(train_tensor)
        """
        1.循环每次计算出来的train_loss代表的是一个批量大小的样本数据的平均损失。
          比如一个批量batch_size的大小为32,那么此处的train_loss即是32个样本数据的平均损失。
        2.train_running_loss += train_loss.item()
            最终循环遍历完所有批量数据(即完成一个epoch)时,train_running_loss为N个批次数据的平均损失,
            N个批次指的是总样本数除以批量大小的商值,也即是一个epoch中的批次数为总样本数除以批量大小。
        3.最终要计算所有每个样本数据的loss的平均损失时,需要先train_running_loss(一个epoch中的批次数据的平均损失)乘以batch_size批量大小
          才得到每个样本数据的loss的总和,然后最终再除以train_data_len(总样本数)才得到每个样本数据的loss的平均损失。
        """
        # 得到该批次下的平均损失
        train_loss = criterion(train_outputs, train_labels)
        # 将该批次的平均损失加到train_running_loss中
        train_running_loss += train_loss.item()
        # 损失反向传播
        train_loss.backward()
        # 优化器更新模型参数
        optimizer.step()
        # 将该批次中正确的标签数量进行累加, 以便之后计算准确率
        train_running_acc += (train_outputs.argmax(1) == train_labels).sum().item()
    return train_running_loss, train_running_acc

#----------------------------------------第三步: 构建模型验证函数-----------------------------------------#
def valid(valid_data_labels):
    """
    description: 验证函数, 在这个过程中将验证模型的在新数据集上的标签, 收集损失和准确率
    :param valid_data_labels: 验证数据和标签的生成器对象
    :return: 整个验证过程的平均损失之和以及正确标签的累加数
    """
    # 定义训练过程的初始损失和准确率累加数
    valid_running_loss = 0.0
    valid_running_acc = 0.0
    # 循环遍历验证数据和标签生成器
    # for valid_tensor, valid_labels in valid_data_labels:
    for valid_tensor, valid_labels in tqdm(valid_data_labels):
        # 不自动更新梯度
        with torch.no_grad():
            # 使用微调网络获得输出
            valid_outputs = net(valid_tensor)
            # 得到该批次下的平均损失
            valid_loss = criterion(valid_outputs, valid_labels)
            # 将该批次的平均损失加到valid_running_loss中
            valid_running_loss += valid_loss.item()
            # 将该批次中正确的标签数量进行累加, 以便之后计算准确率
            valid_running_acc += (valid_outputs.argmax(1) == valid_labels).sum().item()
    return valid_running_loss,  valid_running_acc

#----------------------------------------第四步: 调用训练和验证函数并打印日志-----------------------------------------#
# 定义训练轮数
epochs = 15

# 定义盛装每轮次的损失和准确率列表, 用于制图
all_train_losses = []
all_valid_losses = []
all_train_acc = []
all_valid_acc = []

# 进行指定轮次的训练
for epoch in range(epochs):
    # 打印轮次
    print("Epoch:", epoch + 1)
    tqdm.write("Epoch {}/{}".format(epoch + 1, epochs))

    # 通过数据加载器获得训练数据和验证数据生成器, 以及对应的样本数量
    train_data_labels, valid_data_labels, train_data_len, valid_data_len = data_loader(data_path, batch_size)
    # 调用训练函数进行训练
    train_running_loss, train_running_acc = train(train_data_labels)
    # 调用验证函数进行验证
    valid_running_loss, valid_running_acc = valid(valid_data_labels)
    """
    1.循环每次计算出来的train_loss代表的是一个批量大小的样本数据的平均损失。
      比如一个批量batch_size的大小为32,那么此处的train_loss即是32个样本数据的平均损失。
    2.train_running_loss += train_loss.item()
        最终循环遍历完所有批量数据(即完成一个epoch)时,train_running_loss为N个批次数据的平均损失,
        N个批次指的是总样本数除以批量大小的商值,也即是一个epoch中的批次数为总样本数除以批量大小。
    3.最终要计算所有每个样本数据的loss的平均损失时,需要先train_running_loss(一个epoch中的批次数据的平均损失)乘以batch_size批量大小
      才得到每个样本数据的loss的总和,然后最终再除以train_data_len(总样本数)才得到每个样本数据的loss的平均损失。
    """
    # 计算每一轮的平均损失, train_running_loss和valid_running_loss是每个批次的平均损失之和
    # 因此将它们乘以batch_size就得到了该轮的总损失, 除以样本数即该轮次的平均损失
    train_average_loss = train_running_loss * batch_size / train_data_len
    valid_average_loss = valid_running_loss * batch_size / valid_data_len

    # train_running_acc和valid_running_acc是每个批次的正确标签累加和,
    # 因此只需除以对应样本总数即是该轮次的准确率
    train_average_acc = train_running_acc / train_data_len
    valid_average_acc = valid_running_acc / valid_data_len
    # 将该轮次的损失和准确率装进全局损失和准确率列表中, 以便制图
    all_train_losses.append(train_average_loss)
    all_valid_losses.append(valid_average_loss)
    all_train_acc.append(train_average_acc)
    all_valid_acc.append(valid_average_acc)
    # 打印该轮次下的训练损失和准确率以及验证损失和准确率
    print("Train Loss:", train_average_loss, "|", "Train Acc:", train_average_acc)
    print("Valid Loss:", valid_average_loss, "|", "Valid Acc:", valid_average_acc)


print('Finished Training')

#----------------------------------------第五步: 绘制训练和验证的损失和准确率对照曲线-----------------------------------------#
# 导入制图工具包
import matplotlib.pyplot as plt
from matplotlib.pyplot import MultipleLocator


# 创建第一张画布
plt.figure(0)
# 绘制训练损失曲线
plt.plot(all_train_losses, label="Train Loss")
# 绘制验证损失曲线, 颜色为红色
plt.plot(all_valid_losses, color="red", label="Valid Loss")
# 定义横坐标刻度间隔对象, 间隔为1, 代表每一轮次
x_major_locator=MultipleLocator(1)
# 获得当前坐标图句柄
ax=plt.gca()
# 设置横坐标刻度间隔
ax.xaxis.set_major_locator(x_major_locator)
# 设置横坐标取值范围
plt.xlim(1,epochs)
# 曲线说明在左上方
plt.legend(loc='upper left')
# 保存图片
plt.savefig("./loss.png")

# 创建第二张画布
plt.figure(1)
# 绘制训练准确率曲线
plt.plot(all_train_acc, label="Train Acc")
# 绘制验证准确率曲线, 颜色为红色
plt.plot(all_valid_acc, color="red", label="Valid Acc")
# 定义横坐标刻度间隔对象, 间隔为1, 代表每一轮次
x_major_locator=MultipleLocator(1)
# 获得当前坐标图句柄
ax=plt.gca()
# 设置横坐标刻度间隔
ax.xaxis.set_major_locator(x_major_locator)
# 设置横坐标取值范围
plt.xlim(1,epochs)
# 曲线说明在左上方
plt.legend(loc='upper left')
# 保存图片
plt.savefig("./acc.png")

#----------------------------------------第六步: 模型保存-----------------------------------------#
# 导入时间
import time
# 模型保存时间
time_ = int(time.time())
# 保存路径
MODEL_PATH = './model/BERT_net_%d.pth' % time_
# 保存模型参数
torch.save(net.state_dict(), MODEL_PATH) 
"""
构建主要逻辑服务的步骤:
        第一步: 导入必备工具和配置
        第二步: 完成查询neo4j数据库的函数
        第三步: 编写主要逻辑处理类
        第四步: 编写服务中的主函数
        第五步: 使用gunicorn启动服务
        第六步: 编写测试脚本并进行测试:
"""
#-------------------------------------------第一步: 导入必备工具和配置----------------------------------#
# 服务框架使用Flask
# 导入必备的工具
from flask import Flask
from flask import request
app = Flask(__name__)

# 导入发送http请求的requests工具
import requests
# 导入操作redis数据库的工具
import redis
# 导入加载json文件的工具
import json
# 导入已写好的Unit API调用文件
from unit import unit_chat
# 导入操作neo4j数据库的工具
from neo4j import GraphDatabase
# 从配置文件中导入需要的配置
# NEO4J的连接配置
# from config import NEO4J_CONFIG
# # REDIS的连接配置
# from config import REDIS_CONFIG
# 句子相关模型服务的请求地址
from config import model_serve_url
# 句子相关模型服务的超时时间
from config import TIMEOUT
# 规则对话模版的加载路径
from config import reply_path
# 用户对话信息保存的过期时间
from config import ex_time

REDIS_CONFIG = {
     "host": "0.0.0.0",
     "port": 6379
}
NEO4J_CONFIG = {
    "uri": "bolt://0.0.0.0:7687",
    "auth": ("neo4j", "********"),
    "encrypted": False
}
model_serve_url = "http://0.0.0.0:5001/v1/recognition/"
TIMEOUT = 2
reply_path = "./reply.json"
ex_time = 36000
# 建立REDIS连接池
pool = redis.ConnectionPool(**REDIS_CONFIG)
# 初始化NEO4J驱动对象
_driver = GraphDatabase.driver(**NEO4J_CONFIG)

#-------------------------------------------第二步: 完成查询neo4j数据库的函数----------------------------------#

def query_neo4j(text):
    """
    description: 根据用户对话文本中的可能存在的症状查询图数据库.
    :param text: 用户的输入文本.
    :return: 用户描述的症状对应的疾病列表.
    """
    # 开启一个session操作图数据库
    with _driver.session() as session:
        """ 
        1.第一种方式:直接NEO4J查询语句查询
            NEO4J中存储的为一个个的“疾病名/症状名”名词单词,而查询条件的输入的字符串可以为一句文本,
            也可以为一个“疾病名/症状名”名词单词,底层会通过KMP算法(一种改进的字符串匹配算法)进行查询条件的字符串匹配查询。
            正是因为使用了KMP算法,即使查询条件的字符串是一句文本,不是一个“疾病名/症状名”名词单词,
            也能通过字符串模糊匹配的方式间接抽取出句子中关键的“疾病名/症状名”名词单词进行查询。
        2.第二种方式:
            可以先通过训练好的命名实体模型对查询条件的一句文本字符串进行抽取出关键的“疾病名/症状名”名词单词,
            然后再进行NEO4J查询语句查询,虽然该方式精准度查询更好,但是要求该命名实体模型准确率要比较高,并且预测速度要快,
            才能保证整个系统的实时性。
        """
         # cypher语句, 匹配句子中存在的所有症状节点,
         # 保存这些节点并逐一通过关系dis_to_sym进行对应病症的查找, 返回找到的疾病名字列表.
        cypher = "MATCH(a:Symptom) WHERE(%r contains a.name) WITH a MATCH(a)-[r:dis_to_sym]-(b:Disease) RETURN b.name LIMIT 5" %text
        # 运行这条cypher语句
        record = session.run(cypher)
        # 从record对象中获得结果列表
        result = list(map(lambda x: x[0], record))
    return result

if __name__ == "__main__":
    text = "我最近腹痛!"
    result = query_neo4j(text)
    print("疾病列表:", result) 

上一篇:判断输出三角形周长,面积


下一篇:0242. Valid Anagram (E)