NLP-应用场景-基于医疗“知识图谱”的在线医生智能对话系统【命名实体识别(BiLSTM+CRF模型)->维特比算法预测】【命名实体审核(BERT+RNN)】【句子相关性判断模型(BERT+DNN)】

一、背景介绍

什么是智能对话系统?

随着人工智能技术的发展, 聊天机器人, 语音助手等应用在生活中随处可见, 比如百度的小度, 阿里的小蜜, 微软的小冰等等. 其目的在于通过人工智能技术让机器像人类一样能够进行智能回复, 解决现实中的各种问题.

在这里插入图片描述


从处理问题的角度来区分, 智能对话系统可分为:

  • 任务导向型: 完成具有明确指向性的任务, 比如预定酒店咨询, 在线问诊等等.
  • 非任务导向型: 没有明确目的, 比如算算术, 播放音乐, 回答问题.

我们的在线医生项目就是任务导向型的智能对话系统.


二、Unit对话API的使用

Unit平台是百度大脑开放的智能对话定制与服务平台, 也是当前最大的中文领域对话开放平台之一. Unit对注册用户提供免费的对话接口服务, 比如中文闲聊API, 百科问答API, 诗句生成API等, 通过这些API我们可以感受一下智能对话的魅力, 同时它也可以作为任务导向型对话系统无法匹配用户输入时的最终选择.

在这里插入图片描述


Unit闲聊API演示:

用户输入 >>> "你好"
Unit回复 >>> "你好,想聊什么呢~"
用户输入 >>> "我想有一个女朋友!"
Unit回复 >>> "我也是想要一个女朋友~"
用户输入 >>> "晚吃啥呢想想"
Unit回复 >>> "想吃火锅"

调用Unit API的实现过程:

  • 第一步: 注册登录百度账户, 进入Unit控制台创建自己的机器人.
  • 第二步: 进行相关配置, 获得请求API接口需要的API Key与Secret Key.
  • 第三步: 在服务器上编写API调用脚本并进行测试.

第一步: 注册登录百度账户, 进入Unit控制台创建自己的机器人.

在这里插入图片描述


第二步: 进行相关配置, 获得请求API接口需要的API Key与Secret Key.

  • 点击获取API Key进入百度云应用管理页面.

在这里插入图片描述


  • 点击创建应用, 进入应用信息表单填写页面.

在这里插入图片描述


  • 填写完毕后, 点击立即创建, 成功后会提示创建完毕.

在这里插入图片描述


  • 点击返回应用列表.

在这里插入图片描述


  • 可以看到创建的API Key和Secret Key, 至此创建流程结束.

在这里插入图片描述


第三步: 在服务器上编写API调用脚本并进行测试

import json
import random
import requests

# client_id 为官网获取的AK, client_secret 为官网获取的SK
client_id = "1xhPonkmHqwolDt3GCICLX39"
client_secret = "SRYsfjMGNuW8G265paMXLEjDTjO6O4RC"


def unit_chat(chat_input, user_id="88888"):
    """
    description:调用百度UNIT接口,回复聊天内容
    Parameters
      ----------
      chat_input : str
          用户发送天内容
      user_id : str
          发起聊天用户ID,可任意定义
    Return
      ----------
      返回unit回复内容
    """
    # 设置默认回复内容,  一旦接口出现异常, 回复该内容
    chat_reply = "不好意思,俺们正在学习中,随后回复你。"
    # 根据 client_id 与 client_secret 获取access_token
    url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s" % (
    client_id, client_secret)
    res = requests.get(url)
    access_token = eval(res.text)["access_token"]
    # 根据 access_token 获取聊天机器人接口数据
    unit_chatbot_url = "https://aip.baidubce.com/rpc/2.0/unit/service/chat?access_token=" + access_token
    # 拼装聊天接口对应请求发送数据,主要是填充 query 值
    post_data = {
    
    
                "log_id": str(random.random()),
                "request": {
    
    
                    "query": chat_input,
                    "user_id": user_id
                },
                "session_id": "",
                "service_id": "S23245",
                "version": "2.0"
            }
    # 将封装好的数据作为请求内容, 发送给Unit聊天机器人接口, 并得到返回结果
    res = requests.post(url=unit_chatbot_url, json=post_data)


    # 获取聊天接口返回数据
    unit_chat_obj = json.loads(res.content)
    # print(unit_chat_obj)
    # 打印返回的结果
    # 判断聊天接口返回数据是否出错 error_code == 0 则表示请求正确
    if unit_chat_obj["error_code"] != 0: return chat_reply
    # 解析聊天接口返回数据,找到返回文本内容 result -> response_list -> schema -> intent_confidence(>0) -> action_list -> say
    unit_chat_obj_result = unit_chat_obj["result"]
    unit_chat_response_list = unit_chat_obj_result["response_list"]
    # 随机选取一个"意图置信度"[+response_list[].schema.intent_confidence]不为0的技能作为回答
    unit_chat_response_obj = random.choice(
       [unit_chat_response for unit_chat_response in unit_chat_response_list if
        unit_chat_response["schema"]["intent_confidence"] > 0.0])
    unit_chat_response_action_list = unit_chat_response_obj["action_list"]
    unit_chat_response_action_obj = random.choice(unit_chat_response_action_list)
    unit_chat_response_say = unit_chat_response_action_obj["say"]
    return unit_chat_response_say


if __name__ == '__main__':
    while True:
        chat_input = input("请输入:")
        print(chat_input)
        chat_reply = unit_chat(chat_input)
        print("用户输入 >>>", chat_input)
        print("Unit回复 >>>", chat_reply)
        if chat_input == 'Q' or chat_input == 'q':
            break


代码位置: /data/doctor_online/main_serve/unit.py


调用:

python unit.py


  • 输出效果:
请输入:你好啊
你好啊
用户输入 >>> 你好啊
Unit回复 >>> 你也好啊~
请输入:今天天气棒棒哒
今天天气棒棒哒
用户输入 >>> 今天天气棒棒哒
Unit回复 >>> 必须的
请输入:晚饭吃点什么?
晚饭吃点什么?
用户输入 >>> 晚饭吃点什么?
Unit回复 >>> 晚饭没吃,减肥
请输入:


  • 本章总结:

    • 智能对话系统的相关背景知识:
      • 什么是智能对话系统
      • 从处理问题的目的来区分, 智能对话系统的分类

    • 我们的在线医生项目就是任务导向型的智能对话系统.

    • 学习了Unit平台的相关知识:
      • Unit平台是百度大脑开放的智能对话定制与服务平台, 也是当前最大的中文领域对话开放平台之一.

    • 调用Unit API的实现过程:
      • 第一步: 注册登录百度账户, 进入Unit控制台创建自己的机器人.
      • 第二步: 进行相关配置, 获得请求API接口需要的API Key与Secret Key.
      • 第三步: 在服务器上编写API调用脚本并进行测试.

三、在线医生项目介绍

1、在线医生的总体架构

在这里插入图片描述
在这里插入图片描述

整个项目分为: 在线部分和离线部分

  • 在线部分包括:
    • werobot服务模块(微信信息发送),
    • 主要逻辑服务模块,
    • 句子相关模型服务模块(BERT模型-判断用户的前后两句话是否具有逻辑上、语义上的连贯性),
    • 会话管理模块(redis:将用户说的话存入redis缓存服务器,同时读出用户上一句话),
    • 图数据库模块(Neo4j)
    • 规则对话/Unit模块(把要返回给用户的话以一个规则的形式写出去).
  • 离线部分包括:
    • 结构与非结构化数据采集模块,
    • NER命名实体识别模型(BiLSTM+CRF)使用模块,
    • 实体审核模型(用RNN模型来判断实体到底是否与医学相关)使用模块.
  • 在线部分数据流:
    • 从用户请求开始,
    • 通过werobot服务, 在werobot服务内部请求主服务,
    • 在主服务中将调用会话管理数据库redis,
    • 调用句子相关模型服务, 以及调用图数据库,
    • 最后将查询结果输送给对话规则模版或者使用Unit对话API回复.
  • 离线部分数据流:
    • 从数据采集开始, 将获得结构化和非结构化的数据,
    • 对于结构化数据将直接使用实体审核模型进行审核, 然后写入图数据库;
    • 对于非结构化数据, 将使用NER命名实体识别模型(BiLSTM+CRF)进行实体抽取, 然后通过实体审核后再写入图数据库.

2、总体架构中的工具介绍

总体架构中使用的工具:

  • Flask web服务框架
  • Redis数据库
  • Gunicorn服务组件
  • Supervisor服务监控器
  • Neo4j图数据库

2.1 Flask web服务框架:

在这里插入图片描述

Flask框架是当下最受欢迎的python轻量级框架, 也是pytorch官网指定的部署框架. Flask的基本模式为在程序里将一个视图函数分配给一个URL,每当用户访问这个URL时,系统就会执行给该URL分配好的视图函数,获取函数的返回值,其工作过程见图.

在这里插入图片描述

在项目中, Flask框架是主逻辑服务和句子相关模型服务使用的服务框架.

安装:

# 使用pip安装Flask
pip install Flask==1.1.1

基本使用方法:

# 导入Flask类
from flask import Flask
# 创建一个该类的实例app, 参数为__name__, 这个参数是必需的,
# 这样Flask才能知道在哪里可找到模板和静态文件等东西.
app = Flask(__name__)

# 使用route()装饰器来告诉Flask触发函数的URL
@app.route('/')
def hello_world():
    """请求指定的url后,执行的主要逻辑函数"""
    # 在用户浏览器中显示信息:'Hello, World!'
    return 'Hello, World!'

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=5000)

代码位置: /data/doctor_onine/main_serve/app.py

启动服务:

python app.py

如果在阿里云部署,需要在安全组开放5000端口。服务器防火墙开放5000端口

启动效果: 通过浏览器打开地址http://0.0.0.0:5000可看见打印了’Hello, World’.


2.2 Redis数据库:

在这里插入图片描述

Redis(全称:Remote Dictionary Server 远程字典服务)是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API.

在项目中, Redis用于会话管理数据库, 保存用户聊天历史.

安装:

# 使用yum安装redis
yum install redis -y

基本使用方法:

  • Redis支持四种数据结构的存储: String(字符串), Hash(散列), List(列表), Set(集合), Sorted Set(有序集合).
  • 在这里我们将着重介绍如何在python中使用Hash(散列)进行读写.

安装python中的redis驱动:

# 使用pip进行安装
pip install redis

启动redis服务:

# 启动redis-server, 这里使用了默认配置, 端口是6379.
redis-server

在python中使用Hash(散列)进行读写:

# coding=utf-8
# redis配置
REDIS_CONFIG = {
    
    
     "host": "0.0.0.0",
     "port": 6379
}

# 导入redis驱动
import redis

# 创建一个redis连接池
pool = redis.ConnectionPool( **REDIS_CONFIG)
# 从连接池中初始化一个活跃的连接对象
r = redis.StrictRedis(connection_pool=pool)
# hset表示使用hash数据结构进行数据写入
# uid代表某个用户的唯一标识
uid = "8888"
# key是需要记录的数据描述
key = "该用户最后一次说的话:".encode('utf-8')
# value是需要记录的数据具体内容
value = "再见, 董小姐".encode('utf-8')
r.hset(uid, key, value)


# hget表示使用hash数据结构进行数据读取
result = r.hget(uid, key)
print(result.decode('utf-8'))

输出效果:

再见, 董小姐

2.3 Gunicorn服务组件:

在这里插入图片描述

Gunicorn是一个被广泛使用的高性能的Python WSGI UNIX HTTP服务组件(WSGI: Web Server Gateway Interface),移植自Ruby的独角兽(Unicorn )项目,具有使用非常简单,轻量级的资源消耗,以及高性能等特点.

在项目中, Gunicorn和Flask框架一同使用, 能够开启服务, 处理请求,因其高性能的特点能够有效减少服务丢包率.

安装:

# 使用pip安装gunicorn
pip install gunicorn==20.0.4

基本使用方法:

# 使用其启动Flask服务:
gunicorn -w 1 -b 0.0.0.0:5000 app:app
# -w 代表开启的进程数, 我们只开启一个进程
# -b 服务的IP地址和端口
# app:app 是指执行的主要对象位置, 在app.py中的app对象
(base) [root@whx main_server]# gunicorn -w 1 -b 0.0.0.0:5000 app:app
[2021-03-22 11:43:15 +0800] [6266] [INFO] Starting gunicorn 20.0.4
[2021-03-22 11:43:15 +0800] [6266] [INFO] Listening at: http://0.0.0.0:5000 (6266)
[2021-03-22 11:43:15 +0800] [6266] [INFO] Using worker: sync
[2021-03-22 11:43:15 +0800] [6269] [INFO] Booting worker with pid: 6269

如果使其在后台运行可使用:

# 如果使其在后台运行可使用:
# nohup gunicorn -w 1 -b 0.0.0.0:5000 app:app &

2.4 Supervisor服务监控

Supervisor是用Python开发的一个client/server服务,是Linux/Unix系统下的一个进程管理工具。它可以很方便的监听、启动、停止、重启一个或多个进程, 并守护这些进程。

作用: * 在项目中, Supervisor用于监控和守护主要逻辑服务和redis数据库服务.

安装:

# 使用yum安装supervisor
yum install supervisor -y

基本使用方法:

# 编辑配置文件, 指明监控和守护的进程开启命令, 
# 请查看/data/doctor_online/supervisord.conf文件
# 开启supervisor, -c用于指定配置文件
supervisord -c /data/doctor_online/main_server/supervisord.conf
# 查看监控的进程状态:
supervisorctl status

# main_server                      RUNNING   pid 31609, uptime 0:32:20
# redis                            RUNNING   pid 31613, uptime 0:32:18

开启supervisor命令行:supervisorctl

(base) [root@whx main_server]# supervisorctl
main_server                      FATAL     Exited too quickly (process log may have details)
redis                            RUNNING   pid 6017, uptime 0:24:39
supervisor> 

FATAL说明该程序没启动或出错

开启main_server服务(Flask Web框架)

supervisor> start main_server
main_server: started
supervisor> status
main_server                      RUNNING   pid 6349, uptime 0:00:05
redis                            RUNNING   pid 6017, uptime 0:24:58
supervisor> exit
(base) [root@whx main_server]# 

关闭supervisor:supervisorctl shutdown

# 关闭supervisor
(base) [root@whx main_server]# supervisorctl shutdown 
Shut down
(base) [root@whx main_server]# 

如果程序都处于STOP状态,则 supervisorctl start all批量开启全部服务,或批量停止所有服务 supervisorctl stop all

(base) [root@whx main_server]# supervisorctl start all
main_server: started
redis: started
(base) [root@whx main_server]# supervisorctl status all
main_server                      RUNNING   pid 2881, uptime 0:00:10
redis                            RUNNING   pid 2882, uptime 0:00:10
(base) [root@whx main_server]# 

还可以通过浏览器查看可视化监控页面: http://0.0.0.0:9001(如果在阿里云部署,则使用公网IP)

在这里插入图片描述

2.5 Neo4j图数据库

四、离线部分简要分析

1、离线部分架构图:

在这里插入图片描述

离线部分架构展开图:

在这里插入图片描述

离线部分简要分析:

  • 根据架构展开图图,离线部分可分为两条数据流水线,分别用于处理结构化数据和非结构化数据. 这里称它们为结构化数据流水线和非结构化数据流水线.
  • 结构化数据流水线的组成部分:
    • 结构化数据爬虫: 从网页上抓取结构化的有关医学命名实体的内容.
    • 结构化数据的清洗: 对抓取的内容进行过滤和清洗, 以保留需要的部分.
    • 命名实体审核: 对当前命名实体进行审核, 来保证这些实体符合我们的要求.
    • 命名实体写入数据库: 将审核后的命名实体写入数据库之中, 供在线部分使用.
  • 非结构化数据流水线的组成部分:
    • 非结构化数据爬虫: 从网页上抓取非结构化的包含医学命名实体的文本.
    • 非结构化数据清洗: 对非结构化数据进行过滤和清洗, 以保留需要的部分.
    • 命名实体识别: 使用模型从非结构化文本中获取命名实体.
    • 命名实体审核: 对当前命名实体进行审核, 来保证这些实体符合我们的要求.
    • 命名实体写入数据库: 将审核后的命名实体写入数据库之中, 供在线部分使用.

说明:

  • 因为本项目是以AI为核心的项目, 因为结构化与非结构化的数据爬虫和清洗部分的内容这里不做介绍, 但同学们要知道我们的数据来源.

2、结构化数据流水线

需要进行命名实体审核的数据内容:

...
踝部急性韧带损伤.csv
踝部扭伤.csv
踝部骨折.csv
蹄铁形肾.csv
蹼状阴茎.csv
躁狂抑郁症.csv
躁狂症.csv
躁郁症.csv
躯体形式障碍.csv
躯体感染伴发的精神障碍.csv
躯体感染所致精神障碍.csv
躯体感觉障碍.csv
躯体疾病伴发的精神障碍.csv
转换性障碍.csv
转移性小肠肿瘤.csv
转移性皮肤钙化病.csv
转移性肝癌.csv
转移性胸膜肿瘤.csv
转移性骨肿瘤.csv
轮状病毒性肠炎.csv
轮状病毒所致胃肠炎.csv
软产道异常性难产.csv
...
  • 每个csv文件的名字都是一种疾病名.
  • 文件位置: /data/doctor_offline/structured/noreview/

以躁狂症.csv为例, 有如下内容:

躁郁样
躁狂
行为及情绪异常
心境高涨
情绪起伏大
技术狂躁症
攻击行为
易激惹
思维奔逸
控制不住的联想
精神运动性兴奋
  • csv文件的内容是该疾病对应的症状, 每种症状占一行.

  • 文件位置: /data/doctor_offline/structured/noreview/躁狂症.csv

2.1 进行命名实体审核

进行命名实体审核的工作我们这里使用AI模型实现, 包括训练数据集, 模型训练和使用的整个过程, 因此这里内容以独立一章的形成呈现给大家, 具体参见第五章: 命名实体审核任务.

以躁狂症.csv为例, 审核后的内容只剩下一行内容:

躁郁样

命名实体审核步骤完成之后,删除审核后的可能存在的空文件:

# Linux 命令-- 删除当前文件夹下的空文件
find ./ -name "*" -type f -size 0c | xargs -n 1 rm -f

代码位置: 在/data/doctor_offline/structured/reviewed/目录下执行.

2.2 命名实体写入数据库:

将命名实体写入图数据库的原因:写入的数据供在线部分进行查询,根据用户输入症状来匹配对应疾病.

将命名实体写入图数据库代码:

# 引入相关包
import os
import fileinput
from neo4j import GraphDatabase
from config import NEO4J_CONFIG

driver = GraphDatabase.driver( **NEO4J_CONFIG)

def _load_data(path):
    """
    description: 将path目录下的csv文件以指定格式加载到内存
    :param path:  经历了命名实体审核后的疾病对应症状的csv文件
    :return:      返回疾病字典,存储各个疾病以及与之对应的症状的字典
                  {疾病1: [症状1, 症状2, ...], 疾病2: [症状1, 症状2, ...]
    """
    # 获得疾病csv列表
    disease_csv_list = os.listdir(path)
    # 将后缀.csv去掉, 获得疾病列表
    disease_list = list(map(lambda x: x.split(".")[0], disease_csv_list))
   
    # 初始化一个症状列表, 它里面是每种疾病对应的症状列表
    symptom_list = []
    # 遍历疾病csv列表
    for disease_csv in disease_csv_list:
        # 将疾病csv中的每个症状取出存入symptom列表中
        symptom = list(map(lambda x: x.strip(), 
                           fileinput.FileInput(os.path.join(path, disease_csv))))
        # 过滤掉所有长度异常的症状名
        symptom = list(filter(lambda x: 0<len(x)<100, symptom))
        symptom_list.append(symptom)
    # 返回指定格式的数据
    return dict(zip(disease_list, symptom_list))



def write(path):
    """
    description: 将csv数据写入到neo4j, 并形成图谱
    :param path: 数据文件路径
    """
    # 使用_load_data从持久化文件中加载数据
    disease_symptom_dict = _load_data(path)
    # 开启一个neo4j的session
    with driver.session() as session:
        
        for key, value in disease_symptom_dict.items():
            cypher = "MERGE (a:Disease{name:%r}) RETURN a" %key
            session.run(cypher)
            for v in value:
                cypher = "MERGE (b:Symptom{name:%r}) RETURN b" %v
                session.run(cypher)
                cypher = "MATCH (a:Disease{name:%r}) MATCH (b:Symptom{name:%r}) WITH a,b MERGE(a)-[r:dis_to_sym]-(b)" %(key, v)
                session.run(cypher)
        cypher = "CREATE INDEX ON:Disease(name)"	# 在Disease标签的name属性上创建索引
        session.run(cypher)
        cypher = "CREATE INDEX ON:Symptom(name)"	# 在Symptom标签的name属性上创建索引
        session.run(cypher)

if __name__=="__main__":
	# 输入参数path为csv数据所在路径
	path = "/data/doctor_offline/structured/reviewed/"
	write(path)

代码位置: 在/data/doctor_offline/util/neo4j_util.py

通过可视化管理后台查看写入效果:

在这里插入图片描述

4.3 非结构化数据流水线

需要进行命名实体识别的数据内容:

...
麻疹样红斑型药疹.txt
麻疹病毒肺炎.txt
麻痹性臂丛神经炎.txt
麻风性周围神经病.txt
麻风性葡萄膜炎.txt
黄体囊肿.txt
黄斑囊样水肿.txt
黄斑裂孔性视网膜脱离.txt
黄韧带骨化症.txt
黏多糖贮积症.txt
黏多糖贮积症Ⅰ型.txt
黏多糖贮积症Ⅱ型.txt
黏多糖贮积症Ⅵ型.txt
黏多糖贮积症Ⅲ型.txt
黏多糖贮积症Ⅶ型.txt
黑色丘疹性皮肤病.txt
...
  • 每个txt文件的名字都是一种疾病名.
  • 文件位置: /data/doctor_offline/unstructured/norecognite/
  • 以黑色丘疹性皮肤病.txt为例, 有如下内容:
初呈微小、圆形、皮肤色或黑色增深的丘疹,单个或少数发生于颌部或颊部,皮损逐渐增大增多,数年中可达数百,除眶周外尚分布于面部、颈部和胸上部。皮损大小形状酷似脂溢性角化病及扁平疣鶒。不发生鳞屑,结痂和溃疡,亦无瘙痒及其他主观症状
  • txt中是对该疾病症状的文本描述.
  • 文件位置: /data/doctor_offline/unstructured/norecognite/黑色丘疹性皮肤病.txt

4.3.1 进行命名实体识别

进行命名实体识别的工作我们这里使用AI模型实现, 包括模型训练和使用的整个过程, 因此内容以独立一章的形成呈现给大家, 具体内容在第六章: 命名实体识别任务.

4.3.2 进行命名实体审核

同结构化数据流水线中的命名实体审核.

4.3.3 命名实体写入数据库

同结构化数据流水线中的命名实体写入数据库.

五、命名实体审核【离线部分】

1、任务介绍与模型选用

一般在实体进入数据库存储前, 中间都会有一道必不可少的工序, 就是对识别出来的实体进行合法性的检验, 即命名实体(NE)审核任务. 它的检验过程不使用上下文信息, 更关注于字符本身的组合方式来进行判断, 本质上,它是一项短文本二分类问题.

选用的模型及其原因:

  • 针对短文本任务, 无须捕捉长距离的关系, 因此我们使用了传统的RNN模型来解决, 性能和效果可以达到很好的均衡.
  • 短文本任务往往适合使用字嵌入(Word Embedding)的方式, 但是如果你的训练集不是很大,涉及的字数有限, 那么可以直接使用预训练模型的字向量进行表示即可. 我们这里使用了bert-chinese预训练模型来获得中文汉字的向量表示.

2、训练数据集

训练数据集的样式:

1	手内肌萎缩
0	缩萎肌内手
1	尿黑酸
0	酸黑尿
1	单眼眼前黑影
0	影黑前眼眼单
1	忧郁
0	郁忧
1	红细胞寿命缩短
0	短缩命寿胞细红
1	皮肤黏蛋白沉积
0	积沉白蛋黏肤皮
1	眼神异常
0	常异神眼
1	阴囊坠胀痛
0	痛胀坠囊阴
1	动脉血氧饱和度降低
0	低降度和饱氧血脉动

数据集的相关解释:

  • 这些训练集中的正样本往往是基于人工审核的标准命名实体</.font>.
  • 数据集中的第一列代表标签, 1为正标签, 代表后面的文字是命名实体. 0为负标签, 代表后面的文字不是命名实体.
  • 数据集中的第二列中的命名实体来源于数据库中的症状实体名字, 它是结构化爬虫抓取的数据. 而非命名实体则是它的字符串反转.
  • 正负样本的比例是1:1.

将数据集加载到内存:

import pandas as pd 
from collections import Counter

# 读取数据
train_data_path = "./train_data.csv"
train_data= pd.read_csv(train_data_path, header=None, sep="\t")

# 打印正负标签比例
print(dict(Counter(train_data[0].values)))

# 转换数据到列表形式
train_data = train_data.values.tolist()
print(train_data[:10])

代码位置: /data/doctor_offline/review_model/train.py

输出效果:

# 正负标签比例
{1: 5740, 0: 5740}

# 取出10条训练数据查看
[[1, '枕部疼痛'], [0, '痛疼部枕'], [1, '陶瑟征阳性'], [0, '性阳征瑟陶'], [1, '恋兽型性变态'], [0, '态变性型兽恋'], [1, '进食困难'], [0, '难困食进'], [1, '会阴瘘管或窦道形成'], [0, '成形道窦或管瘘阴会']]

3、BERT中文预训练模型

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

3.1 BERT中文预训练模型作用

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


3.2 使用BERT中文预训练模型对句子编码【Encoder】

不带头的Bert模型本质可以把其看做是新的word2Vec。

import torch
from transformers import BertModel, BertTokenizer

# 获得对应的字符映射器, 它将把中文的每个字映射成一个数字
# tokenizer = torch.hub.load('huggingface/pytorch-transformers', 'tokenizer', 'bert-base-chinese')
# 通过torch.hub(pytorch中专注于迁移学的工具)获得已经训练好的bert-base-chinese模型
# model =  torch.hub.load('huggingface/pytorch-transformers', 'model', 'bert-base-chinese')

# 使用离线bert模型
tokenizer = BertTokenizer.from_pretrained('/opt/huggingface_pretrained_model/bert-base-chinese')    # 加载bert的分词器
model = BertModel.from_pretrained('/opt/huggingface_pretrained_model/bert-base-chinese')    # 加载bert模型,这个路径文件夹下有bert_config.json配置文件和model.bin模型权重文件


def get_bert_encode_for_single(text):
    """
    功能: 使用bert-chinese预训练模型对中文文本进行编码
    text: 要进行编码的中文文本
    return : 使用bert编码后的文本张量表示
    """

    # 首先使用字符映射器对每个汉字进行映射
    # 这里需要注意, bert的tokenizer映射后会为结果前后添加开始和结束标记即101和102
    # 这对于多段文本的编码是有意义的, 但在我们这里没有意义, 因此使用[1:-1]对头和尾进行切片
    indexed_tokens = tokenizer.encode(text)[1:-1]
    tokens_tensor = torch.tensor([indexed_tokens])  # 将列表结果封装成tensor张量
    print("tokens_tensor = {0}".format(tokens_tensor))

    # 预测部分需要使得模型不自动求导
    with torch.no_grad():
        model_result= model(tokens_tensor)  # 调用模型获得隐层输出
    print("type(model_result) = {0}".format(type(model_result)))
    last_hidden_state = model_result[0]
    pooler_output = model_result[1]
    last_hidden_state = last_hidden_state[0]    # 模型的输出都是三维张量,第一维是1,使用[0]来进行降维,只提取我们需要的后两个维度的张量
    return last_hidden_state


if __name__ == '__main__':
    text = "你好,周杰伦"
    last_hidden_state = get_bert_encode_for_single(text)
    print("last_hidden_state.shape = {0}----last_hidden_state = {1}".format(last_hidden_state.shape, last_hidden_state))

代码位置: /data/doctor_offline/review_model/bert_chinese_encode.py

输出效果:

tokens_tensor = tensor([[ 872, 1962,  117, 1453, 3345,  840]])
type(model_result) = <class 'transformers.modeling_outputs.BaseModelOutputWithPoolingAndCrossAttentions'>
last_hidden_state.shape = torch.Size([6, 768])----last_hidden_state = tensor([[ 3.2731e-01, -1.4832e-01, -9.1618e-01,  ..., -4.4088e-01,
         -4.1074e-01, -7.5570e-01],
        [-1.1287e-01, -7.6269e-01, -6.4861e-01,  ..., -8.0478e-01,
         -5.3600e-01, -3.1953e-01],
        [-9.3013e-02, -4.4381e-01, -1.1985e+00,  ..., -3.6624e-01,
         -4.7467e-01, -2.6408e-01],
        [-1.6897e-02, -4.3753e-01, -3.6060e-01,  ..., -3.2451e-01,
         -3.4203e-02, -1.7930e-01],
        [-1.3159e-01, -3.0048e-01, -2.4193e-01,  ..., -4.5756e-02,
         -2.0958e-01, -1.0649e-01],
        [-4.0006e-01, -3.4410e-01, -3.9446e-05,  ...,  1.9081e-01,
          1.7006e-01, -3.6221e-01]])

Process finished with exit code 0

4、构建RNN模型【Decoder】

传统RNN的内部结构图:
在这里插入图片描述

结构解释图:

在这里插入图片描述

内部结构分析:

  • 我们把目光集中在中间的方块部分, 它的输入有两部分, 分别是 h t − 1 h_{t-1} ht1 以及 X t X_{t} Xt, 代表上一时间步的隐层输出, 以及此时间步的输入, 它们进入RNN结构体后, 会"融合"到一起, 这种融合我们根据结构解释可知, 是将二者进行拼接, 形成新的张量 [ h t − 1 , X t ] [h_{t-1}, X_{t}] [ht1,Xt], 之后这个新的张量将通过一个全连接层(线性层), 该层使用tanh作为激活函数, 最终得到该时间步的输出 h t h_t ht, 它将作为下一个时间步的输入和 X t + 1 X_{t+1} Xt+1一起进入结构体. 以此类推.

内部结构过程演示:

在这里插入图片描述

根据结构分析得出内部计算公式:

在这里插入图片描述
激活函数tanh的作用:

  • 用于帮助调节流经网络的值, tanh函数将值压缩在-1和1之间.

在这里插入图片描述

构建RNN模型的代码分析:

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

class RNN(nn.Module):
    def __init__(self, input_size, hidden_size, output_size):   # input_size: 输入张量最后一维的尺寸大小; hidden_size: 隐层张量最后一维的尺寸大小; output_size: 输出张量最后一维的尺寸大小
        super(RNN, self).__init__()
        self.hidden_size = hidden_size
        self.i2h = nn.Linear(input_size + hidden_size, hidden_size)  # 构建从输入到隐含层的线性变化, 输入尺寸是input_size + hidden_size(这是因为在循环网络中, 每次输入都有两部分组成,分别是此时刻的输入和上一时刻产生的输出); 输出尺寸是hidden_size
        self.i2o = nn.Linear(input_size + hidden_size, output_size) # 构建从输入到输出层的线性变化, 输入尺寸还是input_size + hidden_size, 输出尺寸是output_size.
        self.logSoftmax = nn.LogSoftmax(dim=-1)    # 最后需要对输出做softmax处理, 获得结果.

    def forward(self, input_tensor, hidden_tensor):   # input_tensor: 规定尺寸的输入张量; hidden_tensor:规定尺寸的初始化隐层张量
        combined_tensor = torch.cat((input_tensor, hidden_tensor), 1)  # 首先使用torch.cat将 input_tensor 与 hidden_tensor 进行张量拼接
        hidden_tensor = self.i2h(combined_tensor)  # 通过输入层到隐层变换获得hidden张量
        output_tensor = self.i2o(combined_tensor) # 通过输入到输出层变换获得output张量
        print("output_tensor.shape = {0}----output_tensor = {1}".format(output_tensor.shape, output_tensor))
        softmax_output = F.softmax(input=output_tensor, dim=-1)
        print("softmax_output = ", softmax_output)
        log_softmax_output = self.logSoftmax(output_tensor)   # 对输出进行softmax处理
        return log_softmax_output, hidden_tensor    # 返回输出张量和最后的隐层结果

    def initHidden(self):   # 隐层初始化函数
        return torch.zeros(1, self.hidden_size) # 将隐层初始化成为一个 1*hidden_size 的全0张量


if __name__=="__main__":
    input_size = 768    # Bert不带头模型输出的词向量维度
    hidden_size = 128   # 自定义的RNN模型的隐层向量维度
    n_categories = 2    # 分类问题的总类别数量

    input_tensor = torch.rand(1, input_size)    # 随机模拟一个当前时间步的 x_t
    hidden_tensor = torch.rand(1, hidden_size)  # 随机模拟一个上一时间步的 h_{t-1}

    rnn = RNN(input_size, hidden_size, n_categories)
    log_softmax_output, hidden_tensor = rnn(input_tensor, hidden_tensor)    # 获取当前时间步的输出 log_softmax_output,以及当前时间步的隐层向量 hidden_tensor
    print("log_softmax_output:", log_softmax_output)
    print("hidden_tensor:", hidden_tensor)

输出效果:

output_tensor.shape = torch.Size([1, 2])----output_tensor = tensor([[-0.3805,  0.2267]], grad_fn=<AddmmBackward>)
softmax_output =  tensor([[0.3527, 0.6473]], grad_fn=<SoftmaxBackward>)
log_softmax_output: tensor([[-1.0421, -0.4349]], grad_fn=<LogSoftmaxBackward>)
hidden_tensor: tensor([[-0.2870,  0.2608, -0.0235,  0.3382, -0.4202,  0.1983, -0.3219, -0.3075,
         -0.1800,  0.1812,  0.3352,  0.5984,  0.0766,  0.2267, -0.0472, -0.0294,
          0.3889, -0.0497,  0.3746,  0.0733,  0.0832,  0.1924,  0.4202,  0.6193,
          0.5166,  0.4753,  0.1640,  0.1890, -0.1762, -0.1737, -0.0665,  0.3903,
         -0.0221, -0.3334,  0.0368,  0.4379,  0.1949, -0.2566,  0.0909, -0.1718,
          0.3051,  0.3585, -0.3049, -0.3326, -0.0524,  0.2685,  0.2129,  0.0034,
          0.0677,  0.2064, -0.3648,  0.0370, -0.0311, -0.0194, -0.3450, -0.1689,
          1.1491,  0.1977, -0.2004, -0.3144, -0.1280, -0.4351, -0.3791,  0.0955,
         -0.2828,  0.1291,  0.2379,  0.3214,  0.1803, -0.3894,  0.3599,  0.0908,
          0.1693,  0.1310, -0.0266, -0.0401,  0.1506, -0.5393,  0.1613,  0.1090,
         -0.6135,  0.0540,  0.6531,  0.0088, -0.2246, -0.7439, -0.5373,  0.4964,
          0.1570, -0.3024, -0.4857, -0.0315,  0.1187,  0.0443, -0.0596,  0.0199,
         -0.1884,  0.4038, -0.7042,  0.3909, -0.1443, -0.3280,  0.1886,  0.1337,
         -0.0474, -0.0806, -0.0389,  0.5661,  0.1962,  0.1437, -0.0869, -0.4625,
         -0.0970, -0.5744, -0.0508, -0.0936, -0.0883, -0.3739, -0.0337,  0.1410,
          0.0648,  0.0885, -0.2135,  0.1781, -0.3301,  0.3941,  0.0368, -0.5758]],
       grad_fn=<AddmmBackward>)

Process finished with exit code 0

代码位置: /data/doctor_offline/review_model/RNN_MODEL.py

torch.cat演示:

>>> x = torch.randn(2, 3)
>>> x
tensor([[ 0.6580, -1.0969, -0.4614],
        [-0.1034, -0.5790,  0.1497]])
>>> torch.cat((x, x, x), 0)
tensor([[ 0.6580, -1.0969, -0.4614],
        [-0.1034, -0.5790,  0.1497],
        [ 0.6580, -1.0969, -0.4614],
        [-0.1034, -0.5790,  0.1497],
        [ 0.6580, -1.0969, -0.4614],
        [-0.1034, -0.5790,  0.1497]])
>>> torch.cat((x, x, x), 1)
ensor([[ 0.6580, -1.0969, -0.4614,  0.6580, -1.0969, -0.4614,  0.6580,-1.0969, -0.4614],
       [-0.1034, -0.5790,  0.1497, -0.1034, -0.5790,  0.1497, -0.1034,-0.5790,  0.1497]])

5、进行模型训练

进行模型训练的步骤:

  • 第一步: 构建随机选取数据函数.
  • 第二步: 构建模型训练函数.
  • 第三步: 构建模型验证函数.
  • 第四步: 调用训练和验证函数.
  • 第五步: 绘制训练和验证的损失和准确率对照曲线.
  • 第六步: 模型保存.

5.1 第一步: 构建随机选取数据函数

将数据集加载到内存获得的train_data

# 导入bert中文编码的预训练模型
from bert_chinese_encode import get_bert_encode_for_single
# 二、随机选取数据函数
# 1、构建随机选取数据函数
def randomTrainingExample(train_data):  # train_data: 训练集的列表形式数据
    category, line = random.choice(train_data)  # 从train_data随机选择一条数据
    line_tensor = get_bert_encode_for_single(line)  # 将里面的文字使用bert进行编码, 获取编码后的tensor类型数据
    category_tensor = torch.tensor([int(category)]) # 将分类标签封装成tensor
    return category, line, category_tensor, line_tensor # 返回四个结果

# 2、测试随机选取数据函数
for i in range(10): # 选择10条数据进行查看
    category, line, category_tensor, line_tensor = randomTrainingExample(train_data.values.tolist())
    print('category =', category, '/ line =', line, '/category_tensor=', category_tensor, '/line_tensor.shape=', line_tensor.shape)

输出效果:

category = 1 / line = 指甲类似云母 /category_tensor= tensor([1]) /line_tensor.shape= torch.Size([6, 768])
category = 1 / line = 良性假肥大型肌营养不良症 /category_tensor= tensor([1]) /line_tensor.shape= torch.Size([12, 768])
category = 0 / line = 斑紫 /category_tensor= tensor([0]) /line_tensor.shape= torch.Size([2, 768])
category = 1 / line = 肾小管酸化功能障碍 /category_tensor= tensor([1]) /line_tensor.shape= torch.Size([9, 768])
category = 0 / line = 变病外膜硬髓颈 /category_tensor= tensor([0]) /line_tensor.shape= torch.Size([7, 768])
category = 0 / line = 厥昏性过一 /category_tensor= tensor([0]) /line_tensor.shape= torch.Size([5, 768])
category = 0 / line = 宽变端侧近骨股 /category_tensor= tensor([0]) /line_tensor.shape= torch.Size([7, 768])
category = 1 / line = 仰颈时吞咽困难 /category_tensor= tensor([1]) /line_tensor.shape= torch.Size([7, 768])
category = 0 / line = 象危经神主自 /category_tensor= tensor([0]) /line_tensor.shape= torch.Size([6, 768])
category = 1 / line = 面下部头痛 /category_tensor= tensor([1]) /line_tensor.shape= torch.Size([5, 768])

代码位置: /data/doctor_offline/review_model/train.py

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

# 选取损失函数为NLLLoss()
criterion = nn.NLLLoss()
# 学习率为0.005
learning_rate = 0.005


def train(category_tensor, line_tensor):
    """模型训练函数, category_tensor代表类别张量, line_tensor代表编码后的文本张量"""
    # 初始化隐层 
    hidden = rnn.initHidden()
    # 模型梯度归0
    rnn.zero_grad()
    # 遍历line_tensor中的每一个字的张量表示
    for i in range(line_tensor.size()[0]):
        # 然后将其输入到rnn模型中, 因为模型要求是输入必须是二维张量, 因此需要拓展一个维度, 循环调用rnn直到最后一个字
        output, hidden = rnn(line_tensor[i].unsqueeze(0), hidden)
    # 根据损失函数计算损失, 输入分别是rnn的输出结果和真正的类别标签
    loss = criterion(output, category_tensor)
    # 将误差进行反向传播
    loss.backward()

    # 更新模型中所有的参数
    for p in rnn.parameters():
        # 将参数的张量表示与参数的梯度乘以学习率的结果相加以此来更新参数
        p.data.add_(-learning_rate, p.grad.data)

    # 返回结果和损失的值
    return output, loss.item()

代码位置: /data/doctor_offline/review_model/train.py

5.3 第三步: 模型验证函数

def valid(category_tensor, line_tensor):
    """模型验证函数, category_tensor代表类别张量, line_tensor代表编码后的文本张量"""
    # 初始化隐层
    hidden = rnn.initHidden()
    # 验证模型不自动求解梯度
    with torch.no_grad():
        # 遍历line_tensor中的每一个字的张量表示    
        for i in range(line_tensor.size()[0]):
            # 然后将其输入到rnn模型中, 因为模型要求是输入必须是二维张量, 因此需要拓展一个维度, 循环调用rnn直到最后一个字
            output, hidden = rnn(line_tensor[i].unsqueeze(0), hidden)      
        # 获得损失
        loss = criterion(output, category_tensor)
     # 返回结果和损失的值
    return output, loss.item()

代码位置: /data/doctor_offline/review_model/train.py

5.4 第四步: 调用训练和验证函数

构建时间计算函数:

import time
import math

def timeSince(since):
    "获得每次打印的训练耗时, since是训练开始时间"
    # 获得当前时间
    now = time.time()
    # 获得时间差,就是训练耗时
    s = now - since
    # 将秒转化为分钟, 并取整
    m = math.floor(s / 60)
    # 计算剩下不够凑成1分钟的秒数
    s -= m * 60
    # 返回指定格式的耗时
    return '%dm %ds' % (m, s)

代码位置: /data/doctor_offline/review_model/train.py

输入参数:

# 假定模型训练开始时间是10min之前
since = time.time() - 10*60

调用:

period = timeSince(since)
print(period)

输出效果:

10m 0s

调用训练和验证函数并打印日志

# 设置迭代次数为50000步
n_iters = 50000

# 打印间隔为1000步
plot_every = 1000


# 初始化打印间隔中训练和验证的损失和准确率
train_current_loss = 0
train_current_acc = 0
valid_current_loss = 0
valid_current_acc = 0


# 初始化盛装每次打印间隔的平均损失和准确率
all_train_losses = []
all_train_acc = []
all_valid_losses = []
all_valid_acc = []

# 获取开始时间戳
start = time.time()


# 循环遍历n_iters次 
for iter in range(1, n_iters + 1):
    # 调用两次随机函数分别生成一条训练和验证数据
    category, line, category_tensor, line_tensor = randomTrainingExample(train_data)
    category_, line_, category_tensor_, line_tensor_ = randomTrainingExample(train_data)
    # 分别调用训练和验证函数, 获得输出和损失
    train_output, train_loss = train(category_tensor, line_tensor)
    valid_output, valid_loss = valid(category_tensor_, line_tensor_)
    # 进行训练损失, 验证损失,训练准确率和验证准确率分别累加
    train_current_loss += train_loss
    train_current_acc += (train_output.argmax(1) == category_tensor).sum().item()
    valid_current_loss += valid_loss
    valid_current_acc += (valid_output.argmax(1) == category_tensor_).sum().item()
    # 当迭代次数是指定打印间隔的整数倍时
    if iter % plot_every == 0:
        # 用刚刚累加的损失和准确率除以间隔步数得到平均值
        train_average_loss = train_current_loss / plot_every
        train_average_acc = train_current_acc/ plot_every
        valid_average_loss = valid_current_loss / plot_every
        valid_average_acc = valid_current_acc/ plot_every
        # 打印迭代步, 耗时, 训练损失和准确率, 验证损失和准确率
        print("Iter:", iter, "|", "TimeSince:", timeSince(start))
        print("Train Loss:", train_average_loss, "|", "Train Acc:", train_average_acc)
        print("Valid Loss:", valid_average_loss, "|", "Valid Acc:", valid_average_acc)
        # 将结果存入对应的列表中,方便后续制图
        all_train_losses.append(train_average_loss)
        all_train_acc.append(train_average_acc)
        all_valid_losses.append(valid_average_loss)
        all_valid_acc.append(valid_average_acc)
        # 将该间隔的训练和验证损失及其准确率归0
        train_current_loss = 0
        train_current_acc = 0
        valid_current_loss = 0
        valid_current_acc = 0

代码位置: /data/doctor_offline/review_model/train.py

输出效果:

Iter: 1000 | TimeSince: 0m 56s
Train Loss: 0.6127021567507527 | Train Acc: 0.747
Valid Loss: 0.6702297774022868 | Valid Acc: 0.7
Iter: 2000 | TimeSince: 1m 52s
Train Loss: 0.5190641692602076 | Train Acc: 0.789
Valid Loss: 0.5217500487511397 | Valid Acc: 0.784
Iter: 3000 | TimeSince: 2m 48s
Train Loss: 0.5398398997281778 | Train Acc: 0.8
Valid Loss: 0.5844468013737023 | Valid Acc: 0.777
Iter: 4000 | TimeSince: 3m 43s
Train Loss: 0.4700755337187358 | Train Acc: 0.822
Valid Loss: 0.5140456306522071 | Valid Acc: 0.802
Iter: 5000 | TimeSince: 4m 38s
Train Loss: 0.5260879981063878 | Train Acc: 0.804
Valid Loss: 0.5924804099237979 | Valid Acc: 0.796
Iter: 6000 | TimeSince: 5m 33s
Train Loss: 0.4702717279043861 | Train Acc: 0.825
Valid Loss: 0.6675750375208704 | Valid Acc: 0.78
Iter: 7000 | TimeSince: 6m 27s
Train Loss: 0.4734503294042624 | Train Acc: 0.833
Valid Loss: 0.6329268293256277 | Valid Acc: 0.784
Iter: 8000 | TimeSince: 7m 23s
Train Loss: 0.4258338176879665 | Train Acc: 0.847
Valid Loss: 0.5356959595441066 | Valid Acc: 0.82
Iter: 9000 | TimeSince: 8m 18s
Train Loss: 0.45773495503464817 | Train Acc: 0.843
Valid Loss: 0.5413714128659645 | Valid Acc: 0.798
Iter: 10000 | TimeSince: 9m 14s
Train Loss: 0.4856756244019302 | Train Acc: 0.835
Valid Loss: 0.5450502399195044 | Valid Acc: 0.813

5.5 第五步: 绘制训练和验证的损失和准确率对照曲线

import matplotlib.pyplot as plt

plt.figure(0)
plt.plot(all_train_losses, label="Train Loss")
plt.plot(all_valid_losses, color="red", label="Valid Loss")
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")
plt.legend(loc='upper left')
plt.savefig("./acc.png")

代码位置: /data/doctor_offline/review_model/train.py

训练和验证损失对照曲线:
在这里插入图片描述

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

在这里插入图片描述

分析:

  • 损失对照曲线一直下降, 说明模型能够从数据中获取规律,正在收敛, 准确率对照曲线中验证准确率一直上升,最终维持在0.98左右.

5.6 模型保存

# 保存路径
MODEL_PATH = './BERT_RNN.pth'
# 保存模型参数
torch.save(rnn.state_dict(), MODEL_PATH)

代码位置: /data/doctor_offline/review_model/train.py

输出效果:

  • 在/data/doctor_offline/review_model/路径下生成BERT_RNN.pth文件.

5.7 模型训练、评估、绘画、保存完整代码

import random
import torch
import time
import math
from torch import nn
import torch.optim as optim
import pandas as pd
from collections import Counter
import matplotlib.pyplot as plt
from bert_chinese_encode import get_bert_encode_for_single  # 导入bert中文编码的预训练模型
from RNN_MODEL import RNN

# 训练命名实体审核模型

# 一、读取训练数据
train_data_path = './train_data.csv'
train_data = pd.read_csv(train_data_path, header=None, sep='\t')
print("正负标签比例: ", dict(Counter(train_data[0].values)))  # 打印一下正负标签比例
train_data = train_data.values.tolist()
print("打印若干数据展示: ", train_data[:2]) #  [[1, '手掌软硬度异常'], [0, '常异度硬软掌手']]


# 二、随机选取数据函数
# 1、构建随机选取数据函数
def randomTrainingExample(train_data):  # train_data: 训练集的列表形式数据
    category, line = random.choice(train_data)  # 从train_data随机选择一条数据
    line_tensor = get_bert_encode_for_single(line)  # 将里面的文字使用bert进行编码, 获取编码后的tensor类型数据
    category_tensor = torch.tensor([int(category)]) # 将分类标签封装成tensor
    return category, line, category_tensor, line_tensor # 返回四个结果

# 2、测试随机选取数据函数
for i in range(10): # 选择10条数据进行查看
    category, line, category_tensor, line_tensor = randomTrainingExample(train_data)
    print('category =', category, '/ line =', line, '/category_tensor=', category_tensor, '/line_tensor.shape=', line_tensor.shape)


# 三、模型训练函数
rnn = RNN(input_size=768, hidden_size=128, output_size=2)  # input_size: Bert不带头模型输出的词向量维度; hidden_size: 自定义的RNN模型的隐层向量维度; output_size: 分类问题的总类别数量
criterion = nn.NLLLoss()    # 实例化损失函数,选取损失函数为NLLLoss()
optimizer = optim.Adam(rnn.parameters())
learning_rate = 0.005   # 学习率为0.005

# 2、构建训练函数
def train(category_tensor, line_tensor):    # category_tensor: 代表类别标签张量, line_tensor: 代表编码后的文本张量
    hidden = rnn.initHidden()   # 初始化隐层
    rnn.zero_grad() # 模型梯度归0    【optimizer.zero_grad()】
    for i in range(line_tensor.size()[0]):  # 遍历line_tensor中的每一个字符的词向量
        output, hidden = rnn(line_tensor[i].unsqueeze(0), hidden)   # 将当前字符的词向量、上一时间步的隐层张量输入到rnn模型中, 因为模型要求是输入必须是二维张量, 因此当前字符的词向量需要拓展一个维度, 循环调用rnn直到最后一个字
    loss = criterion(output, category_tensor)   # 根据损失函数计算损失, 输入分别是rnn的输出的类别标签结果和真正的类别标签
    loss.backward() # 将误差进行反向传播
    for p in rnn.parameters():  # 更新模型中所有的参数【此步一般用torch.optim实例化的优化器代替当前更新梯度的方法 optimizer.step() 】
        p.data.add_(-learning_rate, p.grad.data)    # 利用梯度下降法更新参数,add_()功能:原地更新【将“参数的张量”p.data与“参数的梯度乘以学习率的结果 (-learning_rate)×p.grad.data”相加以此来更新参数】
    return output, loss.item()  # 返回结果和损失的值

# 四、模型验证函数
def valid(category_tensor, line_tensor):   # category_tensor: 代表类别标签张量, line_tensor: 代表编码后的文本张量
    hidden = rnn.initHidden()   # 初始化隐藏层
    with torch.no_grad():   # 注意: 验证函数中要保证模型不自动求导
        for i in range(line_tensor.size()[0]):  # 遍历line_tensor中的每一个字符的词向量
            output, hidden = rnn(line_tensor[i].unsqueeze(0), hidden)   # 将当前字符的词向量、上一时间步的隐层张量输入到rnn模型中, 因为模型要求是输入必须是二维张量, 因此当前字符的词向量需要拓展一个维度, 循环调用rnn直到最后一个字
        loss = criterion(output, category_tensor)  # 根据损失函数计算损失, 输入分别是rnn的输出的类别标签结果和真正的类别标签
    return output, loss.item()

# 五、时间工具函数【获取每次打印的时间消耗, since是训练开始的时间】
def timeSince(since):
    now = time.time()   # 获取当前的时间
    s = now - since # 获取时间差, 就是时间消耗
    m = math.floor(s/60)    # 获取时间差的分钟数
    s -= m*60   # 获取时间差的秒数
    return '%dm %ds' % (m, s)


if __name__=="__main__":
    n_iters = 1000  # 设置训练的迭代次数
    plot_every = 100    # 设置打印间隔为1000
    # 初始化训练和验证的损失,准确率
    train_current_loss = 0
    train_current_acc = 0
    valid_current_loss = 0
    valid_current_acc = 0
    # 为后续的画图做准备,存储每次打印间隔之间的平均损失和平均准确率
    all_train_loss = []
    all_train_acc = []
    all_valid_loss = []
    all_valid_acc = []
    start = time.time() # 获取整个训练的开始时间
    # 进入主循环,遍历n_iters次
    for iter in range(1, n_iters + 1):
        # 分别调用两次随机获取数据的函数,分别获取训练数据和验证数据
        category, line, category_tensor, line_tensor = randomTrainingExample(train_data)
        category_, line_, category_tensor_, line_tensor_ = randomTrainingExample(train_data)
        # 分别调用训练函数,和验证函数,得到输出和损失
        train_output, train_loss = train(category_tensor, line_tensor)
        valid_output, valid_loss = valid(category_tensor_, line_tensor_)
        # 累加训练的损失,训练的准确率,验证的损失,验证的准确率
        train_current_loss += train_loss
        train_current_acc += (train_output.argmax(1) == category_tensor).sum().item()
        valid_current_loss += valid_loss
        valid_current_acc += (valid_output.argmax(1) == category_tensor_).sum().item()
        # 每隔plot_every次数打印一下信息
        if iter % plot_every == 0:
            train_average_loss = train_current_loss / plot_every
            train_average_acc = train_current_acc / plot_every
            valid_average_loss = valid_current_loss / plot_every
            valid_average_acc = valid_current_acc / plot_every
            # 打印迭代次数,时间消耗,训练损失,训练准确率,验证损失,验证准确率
            print("Iter:", iter, "|", "TimeSince:", timeSince(start))
            print("Train Loss:", train_average_loss, "|", "Train Acc:", train_average_acc)
            print("Valid Loss:", valid_average_loss, "|", "Valid Acc:", valid_average_acc)
            # 将损失,准确率的结果保存起来,为后续的画图使用
            all_train_loss.append(train_average_loss)
            all_train_acc.append(train_average_acc)
            all_valid_loss.append(valid_average_loss)
            all_valid_acc.append(valid_average_acc)
            # 将每次打印间隔的训练损失,准确率,验证损失,准确率,归零操作
            train_current_loss = 0
            train_current_acc = 0
            valid_current_loss = 0
            valid_current_acc = 0

    plt.figure(0)
    plt.plot(all_train_loss, label="Train Loss")
    plt.plot(all_valid_loss, color="red", label="Valid Loss")
    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")
    plt.legend(loc="upper left")
    plt.savefig("./acc.png")

    torch.save(rnn.state_dict(), './BERT_RNN.pth')  # 模型的保存

6、使用模型预测【命名实体审核】

将 /doctor_offline/structured/noreview文件夹中的每个文件(文件名为疾病名称)里的症状通过命名实体审核模型(BERT_RNN.pth)进行审核。

模型预测的实现过程:

import os
import torch
import torch.nn as nn

#将 /doctor_offline/structured/noreview文件夹中的每个文件(文件名为疾病名称)里的症状通过命名实体审核模型(BERT_RNN.pth)进行审核。

from RNN_MODEL import RNN   # 导入RNN模型结构
from bert_chinese_encode import get_bert_encode_for_single  # 导入bert预训练模型编码函数
MODEL_PATH = './BERT_RNN.pth'   # 预加载的模型参数路径

# 隐层节点数, 输入层尺寸, 类别数都和训练时相同即可
input_size = 768
hidden_size = 128
n_categories = 2

# 实例化RNN模型, 并加载保存模型参数
rnn = RNN(input_size=input_size, hidden_size=hidden_size, output_size=n_categories)  # input_size: Bert不带头模型输出的词向量维度; hidden_size: 自定义的RNN模型的隐层向量维度; output_size: 分类问题的总类别数量
rnn.load_state_dict(torch.load(MODEL_PATH))

# 模型测试函数, 它将用在模型预测函数中, 用于调用RNN模型并返回结果
def decoder(encoder_output_line_embedding): # line_tensor代表输入文本的张量表示
    hidden = rnn.initHidden()   # 初始化隐层张量
    for i in range(encoder_output_line_embedding.size()[0]):  # 与训练时相同, 遍历输入文本的每一个字符
        output, hidden = rnn(encoder_output_line_embedding[i].unsqueeze(0), hidden)   # 将其逐次输送给rnn模型
    return output   # 获得rnn模型最终的输出

# 模型预测函数
def predict(input_line):    # 输入参数input_line代表需要预测的文本
    with torch.no_grad():   # 不自动求解梯度
        encoder_output_line_embedding = get_bert_encode_for_single(input_line) # 将input_line字符串使用bert模型进行编码得到词向量输出
        decoder_output = decoder(encoder_output_line_embedding)
        _, topi = decoder_output.topk(1, 1) # 从decoder_output中取出最大值对应的索引, 比较的维度是1
        return topi.item()  # 返回结果数值

# 批量预测函数【待识别的命名实体组成的文件是以疾病名称为csv文件名,文件中的每一行是该疾病对应的症状命名实体】
def batch_predict(input_path, output_path): # input_path: 以原始文本(待识别的命名实体组成的文件)输入路径; output_path: 预测过滤后(去除掉非命名实体的文件)的输出路径
    csv_list = os.listdir(input_path)   # 读取路径下的每一个csv文件名, 装入csv列表之中
    # 遍历每一个csv文件
    for csv in csv_list:
        print("csv = ", csv)
        with open(os.path.join(input_path, csv), "r") as fr:    # 以读的方式打开每一个csv文件
            with open(os.path.join(output_path, csv), "w") as fw:   # 再以写的方式打开输出路径的同名csv文件
                input_lines = fr.readlines()  # 读取csv文件的每一行
                for input_line in input_lines:
                    input_line = input_line.strip()
                    res = predict(input_line)   # 使用模型进行预测
                    if res: # 如果结果为1
                        print("input_line = {0}, res = {1}".format(input_line, res))
                        fw.write(input_line + "\n") # 说明审核成功, 写入到输出csv中
                    else:
                        pass

if __name__=="__main__":
    # 模型预测函数【单样本测试】
    input_line = "点瘀样尖针性发多"
    result = predict(input_line)
    print("result:", result)
    # 批量预测函数
    input_path = "/data/doctor_offline/structured/noreview/"
    output_path = "/data/doctor_offline/structured/reviewed/"
    batch_predict(input_path, output_path)

tensor.topk演示:

>>> tr = torch.randn(1, 2)
>>> tr
tensor([[-0.1808, -1.4170]])
>>> tr.topk(1, 1)
torch.return_types.topk(values=tensor([[-0.1808]]), indices=tensor([[0]]))

代码位置: /data/doctor_offline/review_model/predict.py

输出效果:

  • 在输出路径下生成与输入路径等数量的同名csv文件, 内部的症状实体是被审核的可用实体.

六、命名实体识别(Named Entity Recognition,NER)【离线部分】

1、命名实体识别介绍

命名实体识别(Named Entity Recognition,NER)就是从一段自然语言文本中找出相关实体,并标注出其位置以及类型。是信息提取, 问答系统, 句法分析, 机器翻译等应用领域的重要基础工具, 在自然语言处理技术走向实用化的过程中占有重要地位. 包含行业, 领域专有名词, 如人名, 地名, 公司名, 机构名, 日期, 时间, 疾病名, 症状名, 手术名称, 软件名称等。具体可参看如下示例图:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

1.1 命名实体识别的作用

  • 识别专有名词, 为文本结构化提供支持.
  • 主体识别, 辅助句法分析.
  • 实体关系抽取, 有利于知识推理.

1.2 命名实体识别常用方法:基于规则(正则表达式)、基于模型(BiLSTM+CRF)

基于规则: 针对有特殊上下文的实体, 或实体本身有很多特征的文本, 使用规则的方法简单且有效. 比如抽取文本中物品价格, 如果文本中所有商品价格都是“数字+元”的形式, 则可以通过正则表达式”\d*.?\d+元”进行抽取. 但如果待抽取文本中价格的表达方式多种多样, 例如“一千八百万”, “伍佰贰拾圆”, “2000万元”, 遇到这些情况就要修改规则来满足所有可能的情况. 随着语料数量的增加, 面对的情况也越来越复杂, 规则之间也可能发生冲突, 整个系统也可能变得不可维护. 因此基于规则的方式比较适合半结构化或比较规范的文本中的进行抽取任务, 结合业务需求能够达到一定的效果.

  • 优点: 简单, 快速.
  • 缺点: 适用性差, 维护成本高后期甚至不能维护.

基于模型: 从模型的角度来看, 命名实体识别问题实际上是序列标注问题。序列标注问题指的是模型的输入是一个序列, 包括文字, 时间等, 输出也是一个序列. 针对输入序列的每一个单元, 输出一个特定的标签. 以中文分词任务进行举例, 例如输入序列是一串文字: “我是中国人”, 输出序列是一串标签: “OOBII”, 其中"BIO"组成了一种中文分词最基础的标签体系: B表示这个字是词的开始, I表示词的中间到结尾, O表示其他类型词(也可以用更多的字母来表示标签体系,“BIO”是最基础的一种标签体系). 因此我们可以根据输出序列"OOBII"进行解码, 得到分词结果"我\是\中国人".

  • 序列标注问题涵盖了自然语言处理中的很多任务, 包括语音识别, 中文分词, 机器翻译, 命名实体识别等, 而常见的序列标注模型包括HMM, CRF, RNN, LSTM, GRU等模型.
  • 其中在命名实体识别技术上, 目前主流的命名实体识别技术是通过 “BiLSTM+CRF” 模型进行序列标注, 也是项目中要用到的模型.

1.3 医学文本特征

在这里插入图片描述

  • 简短精炼
  • 形容词相对较少
  • 泛化性相对较小
  • 医学名词错字率比较高
  • 同义词、简称比较多

2、BiLSTM介绍

所谓的BiLSTM,就是(Bidirectional LSTM)双向LSTM. 单向的LSTM模型只能捕捉到从前向后传递的信息, 而双向的网络可以同时捕捉正向信息和反向信息, 使得对文本信息的利用更全面, 效果也更好.

在BiLSTM网络最终的输出层后面增加了一个线性层, 用来将BiLSTM产生的隐藏层输出结果投射到具有某种表达标签特征意义的区间, 具体如下图所示:

在这里插入图片描述
BiLSTM模型实现:

  • 第一步: 实现类的初始化和网络结构的搭建.
  • 第二步: 实现文本向量化的函数.
  • 第三步: 实现网络的前向计算.

2.1 第一步: 实现类的初始化和网络结构的搭建

本段代码构建类BiLSTM, 完成初始化和网络结构的搭建。

总共3层:

  • 词嵌入层,
  • 双向LSTM层,
  • 全连接线性层
# 本段代码构建类BiLSTM, 完成初始化和网络结构的搭建
# 总共3层: 词嵌入层, 双向LSTM层, 全连接线性层
import torch
import torch.nn as nn


class BiLSTM(nn.Module):
    """
    description: BiLSTM 模型定义
    """
    def __init__(self, vocab_size, tag_to_id, input_feature_size, hidden_size,
                 batch_size, sentence_length, num_layers=1, batch_first=True):
        """
        description: 模型初始化
        :param vocab_size:          所有句子包含字符大小
        :param tag_to_id:           标签与 id 对照
        :param input_feature_size:  字嵌入维度( 即LSTM输入层维度 input_size )
        :param hidden_size:         隐藏层向量维度
        :param batch_size:          批训练大小
        :param sentence_length      句子长度
        :param num_layers:          堆叠 LSTM 层数
        :param batch_first:         是否将batch_size放置到矩阵的第一维度
        """
        # 类继承初始化函数
        super(BiLSTM, self).__init__()
        # 设置标签与id对照
        self.tag_to_id = tag_to_id
        # 设置标签大小, 对应BiLSTM最终输出分数矩阵宽度
        self.tag_size = len(tag_to_id)
        # 设定LSTM输入特征大小, 对应词嵌入的维度大小
        self.embedding_size = input_feature_size
        # 设置隐藏层维度, 若为双向时想要得到同样大小的向量, 需要除以2
        self.hidden_size = hidden_size // 2
        # 设置批次大小, 对应每个批次的样本条数, 可以理解为输入张量的第一个维度
        self.batch_size = batch_size
        # 设定句子长度
        self.sentence_length = sentence_length
        # 设定是否将batch_size放置到矩阵的第一维度, 取值True, 或False
        self.batch_first = batch_first
        # 设置网络的LSTM层数
        self.num_layers = num_layers
        
        # 构建词嵌入层: 字向量, 维度为总单词数量与词嵌入维度
        # 参数: 总体字库的单词数量, 每个字被嵌入的维度
        self.embedding = nn.Embedding(vocab_size, self.embedding_size)

        # 构建双向LSTM层: BiLSTM (参数: input_size      字向量维度(即输入层大小),
        #                               hidden_size     隐藏层维度,
        #                               num_layers      层数,
        #                               bidirectional   是否为双向,
        #                               batch_first     是否批次大小在第一位)
        self.bilstm = nn.LSTM(input_size=input_feature_size,
                              hidden_size=self.hidden_size,
                              num_layers=num_layers,
                              bidirectional=True,
                              batch_first=batch_first)

        # 构建全连接线性层: 将BiLSTM的输出层进行线性变换
        self.linear = nn.Linear(hidden_size, self.tag_size)

代码实现位置: /data/doctor_offline/ner_model/bilstm.py

输入参数:

# 参数1:码表与id对照
char_to_id = {
    
    "双": 0, "肺": 1, "见": 2, "多": 3, "发": 4, "斑": 5, "片": 6,
              "状": 7, "稍": 8, "高": 9, "密": 10, "度": 11, "影": 12, "。": 13}

# 参数2:标签码表对照
tag_to_id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}

# 参数3:字向量维度
EMBEDDING_DIM = 200

# 参数4:隐层维度
HIDDEN_DIM = 100

# 参数5:批次大小
BATCH_SIZE = 8

# 参数6:句子长度
SENTENCE_LENGTH = 20

# 参数7:堆叠 LSTM 层数
NUM_LAYERS = 1

调用:

# 初始化模型
model = BiLSTM(vocab_size=len(char_to_id),
               tag_to_id=tag_to_id,
               input_feature_size=EMBEDDING_DIM,
               hidden_size=HIDDEN_DIM,
               batch_size=BATCH_SIZE,
               sentence_length=SENTENCE_LENGTH,
               num_layers=NUM_LAYERS)
print(model)

输出效果:

BiLSTM(
  (embedding): Embedding(14, 200)
  (bilstm): LSTM(200, 50, batch_first=True, bidirectional=True)
  (linear): Linear(in_features=100, out_features=5, bias=True)
)

2.2 第二步:实现文本向量化的函数(将中文文本中的每个字映射为序列化的序号)

将句子中的每一个字符映射到码表中,比如:
char_to_id = {“双”: 0, “肺”: 1, “见”: 2, “多”: 3, “发”: 4, “斑”: 5, “片”: 6, “状”: 7, “稍”: 8, “高”: 9, “密”: 10, “度”: 11, “影”: 12, “。”: 13…}

# 本函数实现将中文文本映射为数字化的张量
def sentence_map(sentence_list, char_to_id, max_length):
    """
    description: 将句子中的每一个字符映射到码表中
    :param sentence: 待映射句子, 类型为字符串或列表
    :param char_to_id: 码表, 类型为字典, 格式为{"字1": 1, "字2": 2}
    :return: 每一个字对应的编码, 类型为tensor
    """
    # 字符串按照逆序进行排序, 不是必须操作
    sentence_list.sort(key=lambda c:len(c), reverse=True)
    # 定义句子映射列表
    sentence_map_list = []
    for sentence in sentence_list:
        # 生成句子中每个字对应的 id 列表
        sentence_id_list = [char_to_id[c] for c in sentence]
        # 计算所要填充 0 的长度
        padding_list = [0] * (max_length-len(sentence))
        # 组合
        sentence_id_list.extend(padding_list)
        # 将填充后的列表加入句子映射总表中
        sentence_map_list.append(sentence_id_list)
    # 返回句子映射集合, 转为标量
    return torch.tensor(sentence_map_list, dtype=torch.long)

代码实现位置: /data/doctor_offline/ner_model/bilstm.py

输入参数:

# 参数1:句子集合
sentence_list = [
    "确诊弥漫大b细胞淋巴瘤1年",
    "反复咳嗽、咳痰40年,再发伴气促5天。",
    "生长发育迟缓9年。",
    "右侧小细胞肺癌第三次化疗入院",
    "反复气促、心悸10年,加重伴胸痛3天。",
    "反复胸闷、心悸、气促2多月,加重3天",
    "咳嗽、胸闷1月余, 加重1周",
    "右上肢无力3年, 加重伴肌肉萎缩半年"]

# 参数2:码表与id对照
char_to_id = {
    
    "<PAD>":0}	# 初始化的码表

# 参数3:句子长度
SENTENCE_LENGTH = 20

调用:

if __name__ == '__main__':
    for sentence in sentence_list:
        # 获取句子中的每一个字
        for _char in sentence:
            # 判断是否在码表 id 对照字典中存在
            if _char not in char_to_id:
                # 加入字符id对照字典
                char_to_id[_char] = len(char_to_id)

    # 将句子转为 id 并用 tensor 包装
    sentences_sequence = sentence_map(sentence_list, char_to_id, SENTENCE_LENGTH)
    print("sentences_sequence:\n", sentences_sequence)

输出效果:

sentences_sequence:
tensor([[14, 15, 16, 17, 18, 16, 19, 20, 21, 13, 22, 23, 24, 25, 26, 27, 28, 29, 30,  0],
        [14, 15, 26, 27, 18, 49, 50, 12, 21, 13, 22, 51, 52, 25, 53, 54, 55, 29, 30,  0],
        [14, 15, 53, 56, 18, 49, 50, 18, 26, 27, 57, 58, 59, 22, 51, 52, 55, 29, 0,  0],
        [37, 63, 64, 65, 66, 55, 13, 22, 61, 51, 52, 25, 67, 68, 69, 70, 71, 13, 0,  0],
        [37, 38, 39,  7,  8, 40, 41, 42, 43, 44, 45, 46, 47, 48,  0,  0,  0,  0, 0,  0],
        [16, 17, 18, 53, 56, 12, 59, 60, 22, 61, 51, 52, 12, 62,  0,  0,  0,  0, 0,  0],
        [ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13,  0,  0,  0,  0,  0, 0,  0],
        [31, 32, 24, 33, 34, 35, 36, 13, 30,  0,  0,  0,  0,  0,  0,  0,  0,  0, 0,  0]])

2.3 第三步: 实现网络的前向计算

将句子利用BiLSTM进行特征计算,分别经过Embedding->BiLSTM->Linear,获得发射矩阵(emission scores)。

BiLSTM层的输出维度是tag_size, 也就是每个单词 w i w_i wi 映射到 各个tag的发射概率值。

# 本函数实现类BiLSTM中的前向计算函数forward()
def forward(self, sentences_sequence):
    """
    description: 将句子利用BiLSTM进行特征计算,分别经过Embedding->BiLSTM->Linear,获得发射矩阵(emission scores)
    :param sentences_sequence: 句子序列对应的编码,
                               若设定 batch_first 为 True,
                               则批量输入的 sequence 的 shape 为(batch_size, sequence_length)
    :return:    返回当前句子特征,转化为 tag_size 的维度的特征
    """
    # 初始化隐藏状态值
    h0 = torch.randn(self.num_layers * 2, self.batch_size, self.hidden_size)
    # 初始化单元状态值
    c0 = torch.randn(self.num_layers * 2, self.batch_size, self.hidden_size)
    # 生成字向量, shape 为(batch, sequence_length, input_feature_size)
    # 注:embedding cuda 优化仅支持 SGD 、 SparseAdam
    input_features = self.embedding(sentences_sequence)
    
    # 将字向量与初始值(隐藏状态 h0 , 单元状态 c0 )传入 LSTM 结构中
    # 输出包含如下内容:
    # 1, 计算的输出特征,shape 为(batch, sentence_length, hidden_size)
    #    顺序为设定 batch_first 为 True 情况, 若未设定则 batch 在第二位
    # 2, 最后得到的隐藏状态 hn , shape 为(num_layers * num_directions, batch, hidden_size)
    # 3, 最后得到的单元状态 cn , shape 为(num_layers * num_directions, batch, hidden_size)
    output, (hn, cn) = self.bilstm(input_features, (h0, c0))
    # 将输出特征进行线性变换,转为 shape 为 (batch, sequence_length, tag_size) 大小的特征
    sequence_features = self.linear(output)
    # 输出线性变换为 tag 映射长度的特征
    return sequence_features

代码实现位置: /data/doctor_offline/ner_model/bilstm.py

输入参数:

# 参数1:标签码表对照
tag_to_id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}

# 参数2:字向量维度
EMBEDDING_DIM = 200

# 参数3:隐层维度
HIDDEN_DIM = 100

# 参数4:批次大小
BATCH_SIZE = 8

# 参数5:句子长度
SENTENCE_LENGTH = 20

# 参数6:堆叠 LSTM 层数
NUM_LAYERS = 1

char_to_id = {
    
    "<PAD>":0}
SENTENCE_LENGTH = 20

调用:

if __name__ == '__main__':
    for sentence in sentence_list:
        for _char in sentence:
            if _char not in char_to_id:
                char_to_id[_char] = len(char_to_id)
    sentence_sequence = sentence_map(sentence_list, char_to_id, SENTENCE_LENGTH)

    model = BiLSTM(vocab_size=len(char_to_id), tag_to_id=tag_to_id, input_feature_size=EMBEDDING_DIM, \
    hidden_size=HIDDEN_DIM, batch_size=BATCH_SIZE, sentence_length=SENTENCE_LENGTH, num_layers=NUM_LAYERS)

    sentence_features = model(sentence_sequence)
    print("sequence_features:\n", sentence_features)

输出效果:

sequence_features:
tensor([[[ 4.0880e-02, -5.8926e-02, -9.3971e-02,  8.4794e-03, -2.9872e-01],
         [ 2.9434e-02, -2.5901e-01, -2.0811e-01,  1.3794e-02, -1.8743e-01],
         [-2.7899e-02, -3.4636e-01,  1.3382e-02,  2.2684e-02, -1.2067e-01],
         [-1.9069e-01, -2.6668e-01, -5.7182e-02,  2.1566e-01,  1.1443e-01],
       	  								...
         [-1.6844e-01, -4.0699e-02,  2.6328e-02,  1.3513e-01, -2.4445e-01],
         [-7.3070e-02,  1.2032e-01,  2.2346e-01,  1.8993e-01,  8.3171e-02],
         [-1.6808e-01,  2.1454e-02,  3.2424e-01,  8.0905e-03, -1.5961e-01],
         [-1.9504e-01, -4.9296e-02,  1.7219e-01,  8.9345e-02, -1.4214e-01]],
        ...
        [[-3.4836e-03,  2.6217e-01,  1.9355e-01,  1.8084e-01, -1.6086e-01],
         [-9.1231e-02, -8.4838e-04,  1.0575e-01,  2.2864e-01,  1.6104e-02],
         [-8.7726e-02, -7.6956e-02, -7.0301e-02,  1.7199e-01, -6.5375e-02],
         [-5.9306e-02, -5.4701e-02, -9.3267e-02,  3.2478e-01, -4.0474e-02],
         [-1.1326e-01,  4.8365e-02, -1.7994e-01,  8.1722e-02,  1.8604e-01],
        								...
         [-5.8271e-02, -6.5781e-02,  9.9232e-02,  4.8524e-02, -8.2799e-02],
         [-6.8400e-02, -9.1515e-02,  1.1352e-01,  1.0674e-02, -8.2739e-02],
         [-9.1461e-02, -1.2304e-01,  1.2540e-01, -4.2065e-02, -8.3091e-02],
         [-1.5834e-01, -8.7316e-02,  7.0567e-02, -8.8845e-02, -7.0867e-02]],

        [[-1.4069e-01,  4.9171e-02,  1.4314e-01, -1.5284e-02, -1.4395e-01],
         [ 6.5296e-02,  9.3255e-03, -2.8411e-02,  1.5143e-01,  7.8252e-02],
         [ 4.1765e-03, -1.4635e-01, -4.9798e-02,  2.7597e-01, -1.0256e-01],
         ...
         [-3.9810e-02, -7.6746e-03,  1.2418e-01,  4.9897e-02, -8.4538e-02],
         [-3.4474e-02, -1.0586e-02,  1.3861e-01,  4.0395e-02, -8.3676e-02],
         [-3.4092e-02, -2.3208e-02,  1.6097e-01,  2.3498e-02, -8.3332e-02],
         [-4.6900e-02, -5.0335e-02,  1.8982e-01,  3.6287e-03, -7.8078e-02],
         [-6.4105e-02, -4.2628e-02,  1.8999e-01, -2.9888e-02, -1.1875e-01]]],
       grad_fn=<AddBackward0>)

输出结果说明: 该输出结果为输入批次中句子的特征, 利用线性变换分别对应到每个汉字在每个tag上的得分. 例如上述标量第一个值:[ 4.0880e-02, -5.8926e-02, -9.3971e-02, 8.4794e-03, -2.9872e-01]表示的意思为第一个句子第一个字分别被标记为[“O”, “B-dis”, “I-dis”, “B-sym”, “I-sym”]的分数, 由此可以判断, 在这个例子中, 第一个字被标注为"O"的分数最高.

2.4 BILSTM模型完整代码

BiLSTM层的输出维度是tag_size, 也就是每个单词 w i w_i wi 映射到 各个tag的发射概率值

# 本段代码构建类BiLSTM, 完成初始化和网络结构的搭建
# 总共3层: 词嵌入层, 双向LSTM层, 全连接线性层
import torch
import torch.nn as nn

# BiLSTM 模型定义
class BiLSTM(nn.Module):
    def __init__(self, vocab_size, tag_to_id, input_feature_size, hidden_size, batch_size, sentence_length, num_layers=1, batch_first=True):
        """
        description: 模型初始化
        :param vocab_size:          所有句子包含字符大小【词汇表总数量】
        :param tag_to_id:           标签与 id 对照【 {"O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}】
        :param input_feature_size:  字嵌入维度( 即LSTM输入层维度 input_size )
        :param hidden_size:         隐藏层向量维度
        :param batch_size:          批训练大小
        :param sentence_length      句子长度
        :param num_layers:          堆叠 LSTM 层数
        :param batch_first:         是否将batch_size放置到矩阵的第一维度
        """
        super(BiLSTM, self).__init__()  # 类继承初始化函数
        self.tag_to_id = tag_to_id  # 设置标签与id对照
        self.tag_size = len(tag_to_id)  # 设置标签大小, 对应BiLSTM最终输出分数矩阵宽度
        self.embedding_size = input_feature_size    # 设定LSTM输入特征大小, 对应词嵌入的维度大小
        self.hidden_size = hidden_size // 2 # 设置隐藏层维度, 若为双向时想要得到同样大小的向量, 需要除以2
        self.batch_size = batch_size    # 设置批次大小, 对应每个批次的样本条数, 可以理解为输入张量的第一个维度
        self.sentence_length = sentence_length  # 设定句子长度
        self.batch_first = batch_first  # 设定是否将batch_size放置到矩阵的第一维度, 取值True, 或False
        self.num_layers = num_layers    # 设置网络的LSTM层数
        self.embedding = nn.Embedding(vocab_size, self.embedding_size)  # 构建词嵌入层; vocab_size: 词汇表总单词数量; embedding_size: 每个字的词嵌入维度
        # 构建BiLSTM层【input_size:词向量维度(即输入层大小); hidden_size: 隐藏层维度; num_layers: 层数; bidirectional: 是否为双向; batch_first: 是否批次大小在第一位)
        self.bilstm = nn.LSTM(input_size=input_feature_size, hidden_size=self.hidden_size, num_layers=num_layers, bidirectional=True, batch_first=batch_first)  # 此处的hidden_size:【self.hidden_size = hidden_size // 2】
        self.linear = nn.Linear(hidden_size, self.tag_size) # 构建全连接线性层: 将BiLSTM的输出层进行线性变换【最终维度是tag的类型数量】 # 此处的hidden_size就是传入的参数hidden_size


    # 本函数实现类BiLSTM中的前向计算函数forward()【将句子利用BiLSTM进行特征计算,分别经过Embedding->BiLSTM->Linear,获得发射矩阵(emission scores),返回当前句子特征,转化为 tag_size 的维度的特征】
    def forward(self, sentences_sequence):  # entences_sequence: 句子序列对应的编码, 若设定 batch_first 为 True,则批量输入的 sequence 的 shape 为(batch_size, sequence_length)
        hidden0 = torch.randn(self.num_layers * 2, self.batch_size, self.hidden_size)    # 初始化隐藏状态值
        cell0 = torch.randn(self.num_layers * 2, self.batch_size, self.hidden_size)    # 初始化cell状态值
        input_features = self.embedding(sentences_sequence) # 生成字向量, shape 为(batch, sequence_length, input_feature_size)【注:embedding cuda 优化仅支持 SGD 、 SparseAdam】
        # bilstm层输出如下内容:
        # 1, output:输出【shape 为(batch_size, sentence_length, hidden_size)】【顺序为设定 batch_first 为 True 情况, 若未设定则 batch_size 在第二位】
        # 2, hn:最后时间步的隐藏状态 【shape 为(num_layers * num_directions, batch_size, hidden_size)】
        # 3, cn:最后时间步的Cell状态【shape 为(num_layers * num_directions, batch_size, hidden_size)】
        output, (hn, cn) = self.bilstm(input_features, (hidden0, cell0))    # 将字向量与初始值(隐藏状态初始值 hidden0 , cell状态初始值 cell0 )传入 LSTM 结构中
        sequence_features = self.linear(output) # 将输出特征进行线性变换,转为 shape 为 (batch, sequence_length, tag_size) 大小的特征
        return sequence_features    # 输出线性变换为 tag 映射长度的特征【最终维度是tag的类型数量】



# 工具函数:本函数实现将中文文本映射为数字化的张量【将句子中的每一个字符映射到码表中, 返回每一个字对应的编码, 类型为tensor】
def sentence_map(sentence_list, char_to_id, max_length):    # sentence: 待映射句子, 类型为字符串或列表; char_to_id: 完整版码表, 类型为字典, 格式为{"字1": 1, "字2": 2};
    sentence_list.sort(key=lambda c:len(c), reverse=True)   # 字符串按照逆序进行排序, 不是必须操作
    sentence_map_list = []  # 定义句子映射列表
    for sentence in sentence_list:
        sentence_id_list = [char_to_id[c] for c in sentence]    # 生成句子中每个字对应的 id 列表【序列化的句子】
        padding_list = [0] * (max_length-len(sentence)) # 计算所要填充 0 的长度
        sentence_id_list.extend(padding_list)   # 组合
        sentence_map_list.append(sentence_id_list)  # 将填充后的列表加入句子映射总表中【序列化的句子列表】
    return torch.tensor(sentence_map_list, dtype=torch.long)    # 返回句子映射集合, 转为张量【返回:序列化的句子列表】

# 工具函数:创建字符-序号映射字典
def char2id(char_to_id, sentence_list):
    for sentence in sentence_list:
        for _char in sentence:
            if _char not in char_to_id:
                char_to_id[_char] = len(char_to_id)
    return char_to_id

if __name__ == '__main__':
    # 参数1:句子集合
    sentence_list = ["确诊弥漫大b细胞淋巴瘤1年", "反复咳嗽、咳痰40年,再发伴气促5天。", "生长发育迟缓9年。", "右侧小细胞肺癌第三次化疗入院"]
    # 参数2:汉字与id对照码表
    char_to_id = {
    
    "<PAD>": 0}  # 初始化的码表
    # 参数3:句子长度
    SENTENCE_LENGTH = 20
    # 参数4:标签码表对照
    tag_to_id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}
    # 参数5:字向量维度
    EMBEDDING_DIM = 200
    # 参数6:隐层维度
    HIDDEN_DIM = 100
    # 参数7:批次大小
    BATCH_SIZE = 4
    # 参数8:堆叠 LSTM 层数
    NUM_LAYERS = 1
    # 1、构建汉字-序号对应码表
    char_to_id = char2id(char_to_id, sentence_list) # 创建char_to_id汉字与id对照码表
    # 2、根据char_to_id码表将字符串句子文本转为序列化表示
    sentence_sequence = sentence_map(sentence_list, char_to_id, SENTENCE_LENGTH)
    print("sentence_sequence.shpae = {0}----sentence_sequence = \n{1}\n".format(sentence_sequence.shape, sentence_sequence))
    # 3、实例化模型
    model = BiLSTM(vocab_size=len(char_to_id), tag_to_id=tag_to_id, input_feature_size=EMBEDDING_DIM, hidden_size=HIDDEN_DIM, batch_size=BATCH_SIZE, sentence_length=SENTENCE_LENGTH, num_layers=NUM_LAYERS)
    print("model: ", model)
    # 4、通过模型得到序列化句子中的每个汉字的tag表达概率【每个汉字在每个tag上的得分】
    sentence_features = model(sentence_sequence)
    print("sentence_features.shpae = {0}----sentence_features = \n{1}\n".format(sentence_features.shape, sentence_features))

输出结果:

ssh://root@47.93.247.255:22/root/anaconda3/bin/python -u /data/doctor_offline/ner_model/bilstm.py
sentence_sequence.shpae = torch.Size([4, 20])----sentence_sequence = 
tensor([[14, 15, 16, 17, 18, 16, 19, 20, 21, 13, 22, 23, 24, 25, 26, 27, 28, 29, 30,  0],
        [37, 38, 39,  7,  8, 40, 41, 42, 43, 44, 45, 46, 47, 48,  0,  0,  0,  0,  0,  0],
        [ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13,  0,  0,  0,  0,  0,  0,  0],
        [31, 32, 24, 33, 34, 35, 36, 13, 30,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0]])

sentence_features.shpae = torch.Size([4, 20, 5])----sentence_features = 
tensor([[[-0.1389,  0.0338,  0.1532, -0.1136, -0.0621],
         [ 0.0874, -0.2526, -0.1340, -0.1838, -0.0949],
         [-0.0882, -0.1751, -0.0677, -0.1099,  0.0795],
         [-0.0999, -0.2465, -0.2183,  0.1657, -0.0813],
         [-0.0022, -0.0362, -0.0282, -0.1996,  0.1867],
         [-0.0015, -0.0256, -0.0995, -0.1907,  0.1462],
         [ 0.1290,  0.1615, -0.1282, -0.0963,  0.0899],
         [ 0.1446, -0.0498, -0.1076,  0.0376, -0.1198],
         [ 0.0719, -0.2300, -0.0392,  0.0354,  0.0492],
         [-0.0085, -0.0866,  0.0783,  0.1802,  0.0266],
         [-0.0266, -0.1050, -0.1074, -0.0714,  0.1258],
         [-0.2334, -0.1496,  0.0507, -0.0399,  0.1245],
         [-0.0549, -0.1062,  0.0189, -0.1190,  0.0947],
         [ 0.2127, -0.0431, -0.0334, -0.0583,  0.0572],
         [ 0.2617, -0.1306, -0.2478,  0.0185, -0.0254],
         [ 0.1582,  0.0231,  0.1112, -0.1882,  0.1195],
         [ 0.3084,  0.0007,  0.2140, -0.2551,  0.2404],
         [ 0.0784,  0.0823, -0.0452, -0.1004, -0.1108],
         [ 0.2645,  0.0199, -0.2783, -0.1242, -0.0405],
         [ 0.1166, -0.0279,  0.1355, -0.2478, -0.0557]],

        [[-0.0221, -0.0436, -0.1802,  0.0448,  0.0517],
         [ 0.0469, -0.0873, -0.1866, -0.1121, -0.0372],
         [ 0.1139,  0.0719, -0.0171, -0.1958,  0.0423],
         [ 0.1153, -0.0461, -0.0654, -0.1380, -0.1022],
         [ 0.0093, -0.0765, -0.1502, -0.1107,  0.0768],
         [ 0.1161,  0.0030, -0.2444, -0.2255,  0.0137],
         [ 0.0762,  0.0662,  0.0076, -0.0135,  0.1286],
         [-0.0092,  0.0267,  0.1190, -0.0239,  0.2241],
         [-0.0475,  0.1052, -0.0475,  0.0289,  0.1960],
         [-0.0270, -0.0362,  0.0288,  0.0927,  0.1177],
         [-0.0535,  0.0816,  0.0935,  0.2074, -0.0816],
         [-0.0061,  0.2228,  0.0605, -0.0023,  0.0238],
         [ 0.1011, -0.0206, -0.0435, -0.3221, -0.0308],
         [ 0.2295,  0.2631,  0.1293, -0.4822,  0.0822],
         [ 0.1453,  0.1953,  0.2544, -0.3759,  0.0442],
         [ 0.1539,  0.2058,  0.2637, -0.3136, -0.0085],
         [ 0.1598,  0.2140,  0.2518, -0.2772, -0.0306],
         [ 0.1591,  0.2153,  0.2263, -0.2427, -0.0502],
         [ 0.1438,  0.1987,  0.1815, -0.1906, -0.0859],
         [ 0.0954,  0.1449,  0.1361, -0.1113, -0.1260]],

        [[ 0.1831, -0.1770, -0.0104,  0.1610, -0.1085],
         [ 0.2623,  0.0652, -0.1827, -0.0236, -0.1678],
         [ 0.1192,  0.0590, -0.1336,  0.0076,  0.1512],
         [-0.0304,  0.1055, -0.1486, -0.0601,  0.0876],
         [-0.0663,  0.0646, -0.0286, -0.0374,  0.2744],
         [ 0.0619,  0.0144, -0.0481, -0.1420,  0.2053],
         [ 0.1240,  0.0207, -0.0548, -0.2478, -0.0184],
         [ 0.0130,  0.0061, -0.1453, -0.1595,  0.1096],
         [-0.0148,  0.2643, -0.0448, -0.1963,  0.0510],
         [-0.0912, -0.1276, -0.0617, -0.0942, -0.1681],
         [ 0.0496,  0.0565, -0.2059, -0.3369,  0.1429],
         [ 0.1273,  0.0750, -0.0227, -0.2329,  0.0736],
         [ 0.1588, -0.0276,  0.0487,  0.0212,  0.1070],
         [ 0.1745,  0.0976,  0.2603, -0.2484, -0.0521],
         [ 0.1703,  0.1468,  0.2871, -0.2596, -0.0569],
         [ 0.1752,  0.1775,  0.3019, -0.2583, -0.0519],
         [ 0.1785,  0.2040,  0.3153, -0.2497, -0.0447],
         [ 0.1776,  0.2328,  0.3282, -0.2260, -0.0351],
         [ 0.1653,  0.2622,  0.3342, -0.1599, -0.0290],
         [ 0.1273,  0.2633,  0.2943, -0.0550,  0.0185]],

        [[ 0.0722,  0.0339, -0.2793, -0.0150,  0.0826],
         [ 0.0678, -0.0308,  0.0347, -0.1229, -0.0095],
         [-0.0262, -0.0251, -0.0107, -0.1373,  0.0980],
         [ 0.0927, -0.1573, -0.1421, -0.0923,  0.1980],
         [ 0.0977,  0.0286,  0.0303,  0.0571,  0.2332],
         [ 0.1933,  0.0145, -0.1637,  0.1374,  0.3501],
         [ 0.1239, -0.0021,  0.0452, -0.0581,  0.0789],
         [ 0.1195,  0.0247,  0.0203,  0.1014,  0.1502],
         [ 0.4034,  0.0358, -0.2396, -0.1338,  0.0848],
         [ 0.2128,  0.1202,  0.2143, -0.2778,  0.0352],
         [ 0.1909,  0.1404,  0.2351, -0.2659, -0.0088],
         [ 0.1854,  0.1564,  0.2455, -0.2601, -0.0272],
         [ 0.1850,  0.1688,  0.2514, -0.2553, -0.0368],
         [ 0.1862,  0.1804,  0.2548, -0.2498, -0.0436],
         [ 0.1881,  0.1928,  0.2560, -0.2419, -0.0499],
         [ 0.1900,  0.2077,  0.2538, -0.2291, -0.0569],
         [ 0.1908,  0.2261,  0.2452, -0.2065, -0.0630],
         [ 0.1866,  0.2479,  0.2237, -0.1647, -0.0619],
         [ 0.1683,  0.2699,  0.1807, -0.0827, -0.0448],
         [ 0.1224,  0.2449,  0.0946,  0.0894, -0.0302]]],
       grad_fn=<AddBackward0>)

Process finished with exit code 0

3、CRF介绍

CRF(全称Conditional Random Fields), 条件随机场. 是给定输入序列的条件下, 求解输出序列的条件概率分布模型.

下面举两个应用场景的例子:

  • 场景一: 假设有一堆日常生活的给小朋友排拍的视频片段, 可能的状态有睡觉、吃饭、喝水、洗澡、刷牙、玩耍等, 大部分情况, 我们是能够识别出视频片段的状态. 但如果你只是看到一小段拿杯子的视频, 在没有前后相连的视频作为前后文参照的情况下, 我们很难知道拿杯子是要刷牙还是喝水. 这时, 可以用到CRF模型.

  • 场景二: 假设有分好词的句子, 我们要判断每个词的词性, 那么对于一些词来说, 如果我们不知道相邻词的词性的情况下, 是很难准确判断每个词的词性的. 这时, 我们也可以用到CRF.

基本定义: 我们将随机变量的集合称为随机过程. 由一个空间变量索引的随机过程, 我们将其称为随机场. 上面的例子中, 做词性标注时, 可以将{名词、动词、形容词、副词}这些词性定义为随机变量, 然后从中选择相应的词性, 而这组随机变量在某种程度上遵循某种概率分布, 将这些词性按照对应的概率赋值给相应的词, 就完成了句子的词性标注.

3.1 马尔科夫假设(HMM) v.s. 条件随机场(CRF)

马尔科夫假设,:当前位置的取值只和与它相邻的位置的值有关, 和它不相邻的位置的值无关。应用到我们上面的词性标注例子中, 可以理解为当前词的词性是根据前一个词和后一个词的词性来决定的, 等效于从词性前后文的概率来给出当前词的词性判断结果.

条件随机场(CRF):现实中可以做如下假设,假设一个动词或者副词后面不会连接同样的动词或者副词, 这样的概率很高. 那么, 可以假定这种给定隐藏状态(也就是词性序列)的情况下, 来计算观测状态的计算过程. 本质上CRF模型考虑到了观测状态这个先验条件, 这也是条件随机场中的条件一词的含义。而隐马尔可夫模型(HMM)不考虑先验条件

3.2 转移概率矩阵

首先假设我们需要标注的实体类型有以下几类:

{
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}

其中dis表示疾病(disease), sym表示症状(symptom), B表示命名实体开头, I表示命名实体中间到结尾, O表示其他类型.

因此我们很容易知道每个字的可能标注类型有以上五种可能性, 那么在一个句子中, 由上一个字到下一个字的概率乘积就有5 × 5种可能性, 具体见下图所示【其中的概率数值是通过模型在所给语料的基础上训练得到每个词的tag,然后统计出的结果】:

在这里插入图片描述
最终训练出来结果大致会如上图所示, 其中下标索引为(i, j)的方格代表如果当前字符是第i行表示的标签, 那么下一个字符表示第j列表示的标签所对应的概率值. 以第二行为例, 假设当前第i个字的标签为B-dis, 那么第i+1个字最大可能出现的概率应该是I-dis.

转移概率矩阵是行数、列数都为tag-size的方阵。

3.3 发射概率矩阵

发射概率, 是指已知当前标签的情况下, 对应所出现各个不同字符的概率. 通俗理解就是当前标签比较可能出现的文字有哪些, 及其对应出现的概率.

下面是几段医疗文本数据的标注结果:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

可以得到以上句子的转移矩阵概率如下(比如:其中 28 表示标记为 “O” 的汉字转移到所有标签汉字的总转移次数):

在这里插入图片描述

对应的发射矩阵可以理解为如下图所示结果(其中:29表示标记为O的所有汉字、符号的总数量):

在这里插入图片描述

在这里插入图片描述

4、BiLSTM+CRF模型【优化方案:BERT代替BiLSTM】

BiLSTM+CRF模型结构:

  1. 模型的标签定义与整体架构
  2. 模型内部的分层展开
  3. CRF层的作用

4.1 模型的标签定义与整体架构

假设我们的数据集中有两类实体-人名, 地名, 与之对应的在训练集中有5类标签如下所示:

B-Person, I-Person, B-Organization, I-Organization, O

# B-Person: 人名的开始
# I-Person: 人名的中间部分
# B-Organization: 地名的开始
# I-Organization: 地名的中间部分
# O: 其他非人名, 非地名的标签

假设一个句子有5个单词构成。序列 ( w 0 , w 1 , w 2 , w 3 , w 4 ) (w_0, w_1, w_2, w_3, w_4) (w0,w1,w2,w3,w4) 中的每一个单元 w i w_i wi 都代表着由一个字。

其中字/词嵌入向量是随机初始化的, 字/词嵌入是通过数据训练得到的, 所有的字/词嵌入在训练过程中都会调整到最优解。

这些字嵌入或词嵌入作为BiLSTM+CRF模型的输入, 而输出的是句子中每个字 w i w_i wi 的标签.

在这里插入图片描述

4.2 模型内部的分层展开

整个模型明显有两层, 第一层是BiLSTM层(输出一个 w i w_i wi t a g j tag_j tagj 的输出发射矩阵), 第二层是CRF层(转移矩阵), 将层的内部展开如下图所示:

在这里插入图片描述

BiLSTM层的输出为每一个标签的预测分值(发射矩阵), 例如对于单词 w 0 w_0 w0, BiLSTM层输出是

1.5 (B-Person), 0.9 (I-Person), 0.1 (B-Organization), 0.08 (I-Organization), 0.05 (O)

这些分值将作为CRF层的输入.

4.3 CRF层的作用

如果没有CRF层, 也可以训练一个BiLSTM命名实体识别模型, 如下图所示:

在这里插入图片描述
由于BiLSTM的输出为单元的每一个标签分值, 我们可以挑选分值最高的一个作为该单元的标签.例如, 对于单词 w 0 w_0 w0, "B-Person"的分值-1.5是所有标签得分中最高的, 因此可以挑选"B-Person"作为单词 w 0 w_0 w0 的预测标签. 同理, 可以得到 w 1 w_1 w1 - “I-Person”, w 2 w_2 w2 - “O”, w 3 w_3 w3 - “B-Organization”, w 4 w_4 w4 - “O”

虽然在没有CRF层的条件下我们也可以只使用BiLSTM模型得到序列中每个单元的预测标签, 但是不能保证标签的预测每次都是正确的。如果出现下图的BiLSTM层输出结果, 则明显预测是错误的.

在这里插入图片描述

CRF层能从训练数据中获得约束性的规则:CRF层可以为最后预测的标签添加一些约束来保证预测的标签是合法的

在训练数据训练的过程中, 这些约束可以通过CRF层自动学习到。比如以下规则:

  1. 句子中的第一个词总是以标签"B-"或者"O"开始, 而不是"I-"开始.
  2. 标签"B-label1 I-label2 I-label3 …", 其中的label1, label2, label3应该属于同一类实体。比如, "B-Person I-Person"是合法的序列, 但是"B-Person I-Organization"是非法的序列.
  3. 标签序列"O I-label"是非法序列, 任意实体标签的首个标签应该是"B-", 而不是"I-"。比如, "O B-label"才是合法的序列

有了上述这些约束, 标签序列的预测中非法序列出现的概率将会大大降低.

4.4 损失函数的定义

BiLSTM层的输出维度是tag_size, 也就是每个单词 w i w_i wi 映射到 各个tag的发射概率值。假设BiLSTM的输出是发射矩阵 P P P, 其中 P ( i , j ) P(i,j) P(i,j) 代表单词 w i w_i wi 映射到 t a g j tag_j tagj 的非归一化概率。

对于CRF层, 假设存在一个转移矩阵 A A A,其中 A ( i , j ) A(i,j) A(i,j)代表 t a g j tag_j tagj 转移到 t a g i tag_i tagi 的概率.

对于输入序列 X X X 对应的输出tag序列 y y y, 定义分数如下(本质上就是发射概率和转移概率的累加和):

S ( X , y ) = ∑ i = 1 n P i , y i + ∑ i = 0 n A y i , y i + 1 S(X,y)=\sum_{i=1}^nP_{i,y_i}+\sum_{i=0}^nA_{y_i,y_{i+1}} S(X,y)=i=1nPi,yi+i=0nAyi,yi+1

利用softmax函数, 为每一个正确的tag序列 y y y 定义一个概率值, 在真实的训练中, 只需要最大化似然概率 p ( y ∣ X ) p(y|X) p(yX)即可, 具体使用对数似然如下:

p ( y ∣ X ) = 贝 叶 斯 概 率 公 式 S ( X , y ) S ( X ) = S ( X , y ) ∑ y ~ ∈ Y x S ( X , y ~ ) = 进 行 s o f t m a x 处 理 e S ( X , y ) ∑ y ~ ∈ Y x e S ( X , y ~ ) p(y|X)\xlongequal{贝叶斯概率公式}\cfrac{S(X,y)}{S(X)}=\cfrac{S(X,y)}{\sum_{\tilde{y}∈Y_x}S(X,\tilde{y})}\xlongequal{进行softmax处理}\cfrac{e^S(X,y)}{\sum_{\tilde{y}∈Y_x}e^{S(X,\tilde{y})}} p(yX) S(X)S(X,y)=y~YxS(X,y~)S(X,y)softmax y~YxeS(X,y~)eS(X,y)

l o g ( p ( y ∣ X ) ) = l o g ( e S ( X , y ) ∑ y ~ ∈ Y x e S ( X , y ~ ) ) = − l o g ( ∑ y ~ ∈ Y x e S ( X , y ~ ) ) + S ( X , y ) log(p(y|X))=log\left(\cfrac{e^{S(X,y)}}{\sum_{\tilde{y}∈Y_x}e^{S(X,\tilde{y})}}\right)=-log\left(\sum_{\tilde{y}∈Y_x}e^{S(X,\tilde{y})}\right)+S(X,y) log(p(yX))=log(y~YxeS(X,y~)eS(X,y))=logy~YxeS(X,y~)+S(X,y)

损失函数:

L o s s = − l o g ( p ( y ∣ X ) ) = l o g ( ∑ y ~ ∈ Y x e S ( X , y ~ ) ) − S ( X , y ) Loss=-log(p(y|X))=log\left(\sum_{\tilde{y}∈Y_x}e^{S(X,\tilde{y})}\right)-S(X,y) Loss=log(p(yX))=logy~YxeS(X,y~)S(X,y)

4.5 BiLSTM+CRF模型的代码实现步骤

  • 第一步: 构建神经网络
  • 第二步: 文本信息张量化
  • 第三步: 计算损失函数第一项的分值
  • 第四步: 计算损失函数第二项的分值
  • 第五步: 维特比算法的实现
  • 第六步: 完善BiLSTM_CRF类的全部功能

4.5.1 第一步: 构建神经网络

# 导入相关包与模块
import torch
import torch.nn as nn

class BiLSTM_CRF(nn.Module):
    def __init__(self, batch_size, vocab_size, tag_to_id, embedding_size, hidden_size, sentence_length, num_layers, batch_first=True):
        '''
        description: 模型初始化
        :param batch_size:          批训练大小
        :param vocab_size:          所有句子包含字符大小【词汇表总数量】
        :param tag_to_id:           标签与 id 对照【 {"O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}】
        :param embedding_size:      字嵌入维度( 即LSTM输入层维度 input_size )
        :param hidden_size:         隐藏层向量维度
        :param sentence_length      句子限制最大长度
        :param num_layers:          堆叠 LSTM 层数
        :param batch_first:         是否将batch_size放置到矩阵的第一维度
        '''
        super(BiLSTM_CRF, self).__init__()  # 继承函数的初始化
        self.tag_to_id = tag_to_id  # 设置标签与id对照
        self.tag_size = len(tag_to_id)   # 设置标签大小,对应 BiLSTM 最终输出分数矩阵宽度
        self.embedding_size = embedding_size  # 设定 LSTM 输入特征大小
        self.hidden_size = hidden_size    # 设置隐藏层维度
        self.vocab_size = vocab_size    # 设置单词总数的大小
        self.num_layers = num_layers    # 设置隐藏层的数量
        self.sentence_length = sentence_length  # 设置语句的最大限制长度
        self.batch_size = batch_size    # 设置批次的大小
        self.batch_first = batch_first  # 设定是否将batch_size放置到矩阵的第一维度, 取值True, 或False

        self.word_embeds = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embedding_size)  # 构建词嵌入层, 两个参数分别是单词总数, 词嵌入维度
        self.lstm = nn.LSTM(embedding_size, hidden_size // 2, num_layers=self.num_layers, bidirectional=True, batch_first=batch_first) # 构建双向LSTM层, 输入参数包括词嵌入维度, 隐藏层大小, 堆叠的LSTM层数, 是否双向标志位
        self.hidden2tag = nn.Linear(hidden_size, self.tag_size)   # 构建全连接线性层, 一端对接LSTM隐藏层, 另一端对接输出层, 相应的维度就是标签数量tag_size
        self.transitions = nn.Parameter(torch.randn(self.tag_size, self.tag_size))    # 初始化转移矩阵, 转移矩阵是一个方阵[tag_size, tag_size]

        # 按照损失函数的定义, 任意的合法句子不会转移到"START_TAG", 因此设置为-10000,任意合法的句子不会从"STOP_TAG"继续向下转移, 也设置为-10000
        self.transitions.data[tag_to_id[START_TAG], :] = -10000
        self.transitions.data[:, tag_to_id[STOP_TAG]] = -10000
        self.hidden = self.init_hidden()    # 初始化隐藏层, 利用单独的类函数init_hidden()来完成

    # 定义类内部专门用于初始化隐藏层的函数【为了符合LSTM的输入要求, 我们返回h0, c0, 这两个张量的shape完全一致】
    def init_hidden(self):
        hidden0 = torch.randn(2 * self.num_layers, self.batch_size, self.hidden_size // 2)
        cell0 = torch.randn(2 * self.num_layers, self.batch_size, self.hidden_size // 2)
        return (hidden0, cell0)

代码实现位置: /data/doctor_offline/ner_model/bilstm_crf.py

4.5.2 第二步: 文本信息张量化

# 工具函数:本函数实现将中文文本映射为数字化的张量【将句子中的每一个字符映射到码表中, 返回每一个字对应的编码, 类型为tensor】
def sentence_map(sentence_list, char_to_id, max_length):    # sentence: 待映射句子, 类型为字符串或列表; char_to_id: 完整版码表, 类型为字典, 格式为{"字1": 1, "字2": 2};
    sentence_list.sort(key=lambda c:len(c), reverse=True)   # 字符串按照逆序进行排序, 不是必须操作
    sentence_map_list = []  # 定义句子映射列表
    for sentence in sentence_list:
        sentence_id_list = [char_to_id[c] for c in sentence]    # 生成句子中每个字对应的 id 列表【序列化的句子】
        padding_list = [0] * (max_length-len(sentence)) # 计算所要填充 0 的长度
        sentence_id_list.extend(padding_list)   # 组合
        sentence_map_list.append(sentence_id_list)  # 将填充后的列表加入句子映射总表中【序列化的句子列表】
    return torch.tensor(sentence_map_list, dtype=torch.long)    # 返回句子映射集合, 转为张量【返回:序列化的句子列表】

# 工具函数:创建字符-序号映射字典
def char2id(char_to_id, sentence_list):
    for sentence in sentence_list:
        for _char in sentence:
            if _char not in char_to_id:
                char_to_id[_char] = len(char_to_id)
    return char_to_id



# 在类中将文本信息经过词嵌入层, BiLSTM层, 线性层的处理, 最终输出句子张量
def _get_lstm_output_features(self, sentence): # 在类中将文本信息经过词嵌入层, BiLSTM层, 线性层的处理, 最终输出句子张量
    self.hidden = self.init_hidden()
    word_embedding = self.word_embeds(sentence)
    embeds = word_embedding.view(self.sentence_length, self.batch_size, -1)
    lstm_out, self.hidden = self.lstm(embeds, self.hidden)  # LSTM的两个输入参数: 词嵌入后的张量, 随机初始化的隐藏层张量(hidden0, cell0)【输入要求形状为 [sentence_length, batch_size, embedding_dim],隐藏层hidden,cell要求形状为 [num_layers * direction, batch_size, hidden_size]】
    lstm_out = lstm_out.view(self.sentence_length, self.batch_size, self.hidden_size)   # 要保证输出张量的shape: [sentence_length, batch_size, hidden_size]
    lstm_output_features = self.hidden2tag(lstm_out)  # 将BiLSTM的输出经过一个全连接层, 得到输出张量shape:[sentence_length, batch_size, tag_size]
    print("lstm_output_features.shape = ", lstm_output_features.shape)
    return lstm_output_features

代码实现位置: /data/doctor_offline/ner_model/bilstm_crf.py

4.5.3 第三步: 计算损失函数第一项的分值

# 若干辅助函数, 在类BiLSTM外部定义, 目的是辅助log_sum_exp()函数的计算
# 工具函数:将Variable类型变量内部的真实值, 以python float类型返回
def to_scalar(var): # var是Variable, 维度是1
    return var.view(-1).data.tolist()[0]


# 工具函数:获取最大值的下标【返回列的维度上的最大值下标, 此下标是一个标量float】
def argmax(vec):
    _, idx = torch.max(vec, 1)
    return to_scalar(idx)


# 工具函数:
def log_sum_exp(vec): # vec.size()维度是1 * 7, type是Variable
    max_score = vec[0, argmax(vec)]
    max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])  #  # max_score维度是1, max_score.view(1,-1)维度是1 * 1, max_score.view(1, -1).expand(1, vec.size()[1])的维度1 * 7【经过expand()之后的张量, 里面所有的值都相同, 都是最大值max_score】
    return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))   # 先减去max_score,最后再加上max_score, 是为了防止数值爆炸, 纯粹是代码上的小技巧


# 损失函数第一项的分值函数【本质上是发射矩阵和转移矩阵的累加和】
def _forward_alg(self, features):
    result = torch.zeros((1, self.batch_size))  # 初始化最终的结果张量, 每个句子对应一个分数

    init_alphas = torch.full((1, self.tag_size), -10000.)    # 初始化一个alphas张量, 代表转移矩阵的起始位置【init_alphas: [1, 7] , [-10000, -10000, -10000, -10000, -10000, -10000, -10000]】
    init_alphas[0][self.tag_to_id[START_TAG]] = 0.  # 仅仅把START_TAG赋值为0, 代表着接下来的转移只能从START_TAG开始
    forward_var = init_alphas   # 将初始化的 init_alphas 赋值给前向计算变量, 这样在反向求导的过程中就可以自动更新参数
    features = features.transpose(1, 0)   # 输入进来的features: [20, 8, 7], 为了接下来按句子进行计算, 要将batch_size放在第一个维度上【features: [8, 20, 7]是一个3维矩阵, 最外层代表8个句子, 内层代表每个句子有20个字符,每一个字符映射成7个标签的发射概率】

    idx = 0
    for feat_line in features: # 按行遍历, 总共循环batch_size次【features:[8,20,7]; feat_line:[20,7]】
        for feat in feat_line:  # 遍历一行语句, 每一个feat代表一个time_step(一个汉字或符号)
            alphas_t = []   # 当前time_step 初始化一个前向计算张量
            for next_tag in range(self.tag_size):    # 在当前time_step, 遍历所有可能的转移标签, 进行累加计算
                emit_score = feat[next_tag].view(1, -1).expand(1, self.tag_size) # 广播发射矩阵的分数
                trans_score = self.transitions[next_tag].view(1, -1)    # 第i个time_step循环时, 转移到next_tag标签的转移概率
                next_tag_var = forward_var + trans_score + emit_score   # 将前向矩阵, 转移矩阵, 发射矩阵累加
                # 计算log_sum_exp()函数值,并添加到 alphas_t 列表中
                log_sum_exp_result = log_sum_exp(next_tag_var).view(1) # 注意: log_sum_exp()函数仅仅返回一个实数值【log_sum_exp_result.view(1)的操作就是将一个数字变成一个一阶矩阵, ([]) -> ([1])】
                alphas_t.append(log_sum_exp_result)
            alphas_t = torch.cat(alphas_t)
            forward_var = alphas_t.view(1, -1)  # 将列表张量转变为二维张量
        terminal_var = forward_var + self.transitions[self.tag_to_id[STOP_TAG]] # 添加最后一步转移到"STOP_TAG"的分数, 就完成了整条语句的分数计算
        alpha = log_sum_exp(terminal_var)   # 计算log_sum_exp()函数值, 作为一条样本语句的最终得分
        result[0][idx] = alpha  # 将得分添加到最终的结果列表中, 作为函数结果返回
        idx += 1
    return result

代码实现位置: /data/doctor_offline/ner_model/bilstm_crf.py

4.5.4 第四步: 计算损失函数第二项的分值

# 损失函数第二项的分值函数
def _score_sentence(self, features, tags): # features: 经历了_get_lstm_output_features()处理后的特征张量,形状为:[20, 8, 7] , tags代表训练语句真实的标签矩阵,形状: [8, 20]
    result = torch.zeros((1, self.batch_size))  # 初始化最终的结果分数张量, 每一个句子得到一个分数

    score = torch.zeros(1)  # 初始化一个0值的tensor, 为后续累加做准备
    temp = torch.full((self.batch_size, 1), self.tag_to_id[START_TAG]).clone().detach()
    tags = torch.cat((temp, tags), dim=1)   # 将START_TAG和真实标签tags做列维度上的拼接【在tags矩阵的第一列添加全部都是START_TAG的一列】
    features = features.transpose(1, 0)   # 将传入的features形状转变为[bathc_size, sentence_length, tag_size]

    idx = 0
    for feat_line in features:    # 按行遍历, 总共循环batch_size次【features:[8,20,7]; feat_line:[20,7]】
        for i, feat in enumerate(feat_line):    # 遍历一行语句, 每一个feat代表一个time_step(一个汉字或符号)【注意: 此处区别于第三步的循环, 最重要的是这是在真实标签指导下的转移矩阵和发射矩阵的累加分数】
            score = score + self.transitions[tags[idx][i + 1], tags[idx][i]] + feat[tags[idx][i + 1]]   # 将前向矩阵, 转移矩阵, 发射矩阵累加
        score = score + self.transitions[self.tag_to_id[STOP_TAG], tags[idx][-1]]   # 最遍历完当前语句所有的时间步之后,最后加上转移到STOP_TAG的分数
        result[0][idx] = score
        idx += 1
    return result

代码实现位置: /data/doctor_offline/ner_model/bilstm_crf.py

4.5.5 第五步: 利用维特比算法实现预测

# 根据传入的语句特征features, 根据维特比算法推断出标签序列【贪心算法、beam search算法、维特比算法都是在预测时使用的】
def _viterbi_decode(self, features):
    result_best_path = []   # 初始化最佳路径结果的存放列表
    features = features.transpose(1, 0)   # 将输入张量变形为[batch_size, sequence_length, tag_size]
    for feat_line in features: # 对批次中的每一行语句进行遍历, 每个语句产生一个最优标注序列
        backpointers = []
        init_vvars = torch.full((1, self.tag_size), -10000.)    # 初始化前向传播的张量,
        init_vvars[0][self.tag_to_id[START_TAG]] = 0    # 设置START_TAG等于0, 约束合法序列只能从START_TAG开始
        forward_var = init_vvars    # 在第i个time_step, 张量forward_var保存第i-1个time_step的viterbi变量
        for feat in feat_line:  # 依次遍历i=0, 到序列最后的每一个time_step
            bptrs_t = []    # 保存当前time_step的回溯指针
            viterbivars_t = []  # 保存当前time_step的viterbi变量
            for next_tag in range(self.tag_size):
                # next_tag_var[i]保存了tag_i 在前一个time_step的viterbi变量
                next_tag_var = forward_var + self.transitions[next_tag] # 前向传播张量forward_var加上从tag_i转移到next_tag的分数, 赋值给next_tag_var【注意此处没有加发射矩阵分数, 因为求最大值不需要发射矩阵】
                # 将最大的标签id加入到当前time_step的回溯列表中
                best_tag_id = argmax(next_tag_var)
                bptrs_t.append(best_tag_id)
                viterbivars_t.append(next_tag_var[0][best_tag_id].view(1))
            forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1) # 此处再将发射矩阵分数feat加上, 赋值给forward_var, 作为下一个time_step的前向传播张量
            backpointers.append(bptrs_t)    # 当前time_step的回溯指针添加进当前这一行样本的总体回溯指针中
        terminal_var = forward_var + self.transitions[self.tag_to_id[STOP_TAG]] # 最后加上转移到STOP_TAG的分数
        best_tag_id = argmax(terminal_var)
        path_score = terminal_var[0][best_tag_id]   # path_score是整个路径的总得分
        # 根据回溯指针, 解码最佳路径
        best_path = [best_tag_id]   # 首先把最后一步的id值加入
        for bptrs_t in reversed(backpointers):  # 从后向前回溯最佳路径
            # 通过第i个time_step得到的最佳id, 找到第i-1个time_step的最佳id
            best_tag_id = bptrs_t[best_tag_id]
            best_path.append(best_tag_id)
        start = best_path.pop() # 将START_TAG删除
        assert start == self.tag_to_id[START_TAG]   # 确认一下最佳路径中的第一个标签是START_TAG
        best_path.reverse() # 因为是从后向前回溯, 所以再次逆序得到总前向后的真实路径
        result_best_path.append(best_path)  # 当前这一行的样本结果添加到最终的结果列表里
    return result_best_path

代码实现位置/data/doctor_offline/ner_model/bilstm_crf.py

4.5.6 第六步: 完善BiLSTM类的全部功能

# 对数似然函数的计算, 输入的是数字化编码后的语句, 和真实的标签【注意: 这个函数是真实“训练中”要用到的"虚拟化的forward()"】
def neg_log_likelihood(self, sentence, tags):
    features = self._get_lstm_output_features(sentence)   # 第一步先得到BiLSTM层的输出特征张量【features : [20, 8, 7] 代表一个批次有8个样本, 每个样本长度20,每一个word映射到7个标签的概率, 发射矩阵】
    forward_score = self._forward_alg(features)    # forward_score 代表公式推导中损失函数loss的第一项
    print("forward_score = {0}".format(forward_score))
    gold_score = self._score_sentence(features, tags)  # gold_score 代表公式推导中损失函数loss的第二项
    print("gold_score = {0}".format(gold_score))
    return torch.sum(forward_score - gold_score, dim=1) # 按行求和, 在torch.sum()函数值中, 需要设置dim=1 ; 同理, dim=0代表按列求和【注意: 在这里, 通过forward_score和gold_score的差值来作为loss, 用来梯度下降训练模型】


# 此处的forward()真实场景是用在预测部分, 训练的时候并没有用到【训练的时候用neg_log_likelihood】
def forward(self, sentence):
    lstm_output_features = self._get_lstm_output_features(sentence)  # 获取从BiLSTM层得到的发射矩阵
    predict_tags_result = self._viterbi_decode(lstm_output_features)  # 通过维特比算法直接解码最佳路径
    return predict_tags_result

代码实现位置: /data/doctor_offline/ner_model/bilstm_crf.py

4.6 BiLSTM+CRF模型完整代码

import torch
import torch.nn as nn
import torch.optim as optim

START_TAG = "<START>"
STOP_TAG = "<STOP>"


# 工具函数:本函数实现将中文文本映射为数字化的张量【将句子中的每一个字符映射到码表中, 返回每一个字对应的编码, 类型为tensor】
def sentence_map(sentence_list, char_to_id, max_length):    # sentence: 待映射句子, 类型为字符串或列表; char_to_id: 完整版码表, 类型为字典, 格式为{"字1": 1, "字2": 2};
    sentence_list.sort(key=lambda c:len(c), reverse=True)   # 字符串按照逆序进行排序, 不是必须操作
    sentence_map_list = []  # 定义句子映射列表
    for sentence in sentence_list:
        sentence_id_list = [char_to_id[c] for c in sentence]    # 生成句子中每个字对应的 id 列表【序列化的句子】
        padding_list = [0] * (max_length-len(sentence)) # 计算所要填充 0 的长度
        sentence_id_list.extend(padding_list)   # 组合
        sentence_map_list.append(sentence_id_list)  # 将填充后的列表加入句子映射总表中【序列化的句子列表】
    return torch.tensor(sentence_map_list, dtype=torch.long)    # 返回句子映射集合, 转为张量【返回:序列化的句子列表】

# 工具函数:创建字符-序号映射字典
def char2id(char_to_id, sentence_list):
    for sentence in sentence_list:
        for _char in sentence:
            if _char not in char_to_id:
                char_to_id[_char] = len(char_to_id)
    return char_to_id



# 工具函数:将Variable类型变量内部的真实值, 以python float类型返回
def to_scalar(var): # var是Variable, 维度是1
    return var.view(-1).data.tolist()[0]


# 工具函数:获取最大值的下标【返回列的维度上的最大值下标, 此下标是一个标量float】
def argmax(vec):
    _, idx = torch.max(vec, 1)
    return to_scalar(idx)


# 工具函数:
def log_sum_exp(vec): # vec.size()维度是1 * 7, type是Variable
    max_score = vec[0, argmax(vec)]
    max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])  #  # max_score维度是1, max_score.view(1,-1)维度是1 * 1, max_score.view(1, -1).expand(1, vec.size()[1])的维度1 * 7【经过expand()之后的张量, 里面所有的值都相同, 都是最大值max_score】
    return max_score + torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))   # 先减去max_score,最后再加上max_score, 是为了防止数值爆炸, 纯粹是代码上的小技巧



class BiLSTM_CRF(nn.Module):
    def __init__(self, batch_size, vocab_size, tag_to_id, embedding_size, hidden_size, sentence_length, num_layers, batch_first=True):
        '''
        description: 模型初始化
        :param batch_size:          批训练大小
        :param vocab_size:          所有句子包含字符大小【词汇表总数量】
        :param tag_to_id:           标签与 id 对照【 {"O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4}】
        :param embedding_size:      字嵌入维度( 即LSTM输入层维度 input_size )
        :param hidden_size:         隐藏层向量维度
        :param sentence_length      句子限制最大长度
        :param num_layers:          堆叠 LSTM 层数
        :param batch_first:         是否将batch_size放置到矩阵的第一维度
        '''
        super(BiLSTM_CRF, self).__init__()  # 继承函数的初始化
        self.tag_to_id = tag_to_id  # 设置标签与id对照
        self.tag_size = len(tag_to_id)   # 设置标签大小,对应 BiLSTM 最终输出分数矩阵宽度
        self.embedding_size = embedding_size  # 设定 LSTM 输入特征大小
        self.hidden_size = hidden_size    # 设置隐藏层维度
        self.vocab_size = vocab_size    # 设置单词总数的大小
        self.num_layers = num_layers    # 设置隐藏层的数量
        self.sentence_length = sentence_length  # 设置语句的最大限制长度
        self.batch_size = batch_size    # 设置批次的大小
        self.batch_first = batch_first  # 设定是否将batch_size放置到矩阵的第一维度, 取值True, 或False

        self.word_embeds = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embedding_size)  # 构建词嵌入层, 两个参数分别是单词总数, 词嵌入维度
        self.lstm = nn.LSTM(embedding_size, hidden_size // 2, num_layers=self.num_layers, bidirectional=True, batch_first=batch_first) # 构建双向LSTM层, 输入参数包括词嵌入维度, 隐藏层大小, 堆叠的LSTM层数, 是否双向标志位
        self.hidden2tag = nn.Linear(hidden_size, self.tag_size)   # 构建全连接线性层, 一端对接LSTM隐藏层, 另一端对接输出层, 相应的维度就是标签数量tag_size
        self.transitions = nn.Parameter(torch.randn(self.tag_size, self.tag_size))    # 初始化转移矩阵, 转移矩阵是一个方阵[tag_size, tag_size]

        # 按照损失函数的定义, 任意的合法句子不会转移到"START_TAG", 因此设置为-10000,任意合法的句子不会从"STOP_TAG"继续向下转移, 也设置为-10000
        self.transitions.data[tag_to_id[START_TAG], :] = -10000
        self.transitions.data[:, tag_to_id[STOP_TAG]] = -10000
        self.hidden = self.init_hidden()    # 初始化隐藏层, 利用单独的类函数init_hidden()来完成

    # 定义类内部专门用于初始化隐藏层的函数【为了符合LSTM的输入要求, 我们返回h0, c0, 这两个张量的shape完全一致】
    def init_hidden(self):
        hidden0 = torch.randn(2 * self.num_layers, self.batch_size, self.hidden_size // 2)
        cell0 = torch.randn(2 * self.num_layers, self.batch_size, self.hidden_size // 2)
        return (hidden0, cell0)


    def _get_lstm_output_features(self, sentence): # 在类中将文本信息经过词嵌入层, BiLSTM层, 线性层的处理, 最终输出句子张量
        self.hidden = self.init_hidden()
        word_embedding = self.word_embeds(sentence)
        embeds = word_embedding.view(self.sentence_length, self.batch_size, -1)
        lstm_out, self.hidden = self.lstm(embeds, self.hidden)  # LSTM的两个输入参数: 词嵌入后的张量, 随机初始化的隐藏层张量(hidden0, cell0)【输入要求形状为 [sentence_length, batch_size, embedding_dim],隐藏层hidden,cell要求形状为 [num_layers * direction, batch_size, hidden_size]】
        lstm_out = lstm_out.view(self.sentence_length, self.batch_size, self.hidden_size)   # 要保证输出张量的shape: [sentence_length, batch_size, hidden_size]
        lstm_output_features = self.hidden2tag(lstm_out)  # 将BiLSTM的输出经过一个全连接层, 得到输出张量shape:[sentence_length, batch_size, tag_size]
        print("lstm_output_features.shape = ", lstm_output_features.shape)
        return lstm_output_features

    # 损失函数第一项的分值函数【本质上是发射矩阵和转移矩阵的累加和】
    def _forward_alg(self, features):
        result = torch.zeros((1, self.batch_size))  # 初始化最终的结果张量, 每个句子对应一个分数

        init_alphas = torch.full((1, self.tag_size), -10000.)    # 初始化一个alphas张量, 代表转移矩阵的起始位置【init_alphas: [1, 7] , [-10000, -10000, -10000, -10000, -10000, -10000, -10000]】
        init_alphas[0][self.tag_to_id[START_TAG]] = 0.  # 仅仅把START_TAG赋值为0, 代表着接下来的转移只能从START_TAG开始
        forward_var = init_alphas   # 将初始化的 init_alphas 赋值给前向计算变量, 这样在反向求导的过程中就可以自动更新参数
        features = features.transpose(1, 0)   # 输入进来的features: [20, 8, 7], 为了接下来按句子进行计算, 要将batch_size放在第一个维度上【features: [8, 20, 7]是一个3维矩阵, 最外层代表8个句子, 内层代表每个句子有20个字符,每一个字符映射成7个标签的发射概率】

        idx = 0
        for feat_line in features: # 按行遍历, 总共循环batch_size次【features:[8,20,7]; feat_line:[20,7]】
            for feat in feat_line:  # 遍历一行语句, 每一个feat代表一个time_step(一个汉字或符号)
                alphas_t = []   # 当前time_step 初始化一个前向计算张量
                for next_tag in range(self.tag_size):    # 在当前time_step, 遍历所有可能的转移标签, 进行累加计算
                    emit_score = feat[next_tag].view(1, -1).expand(1, self.tag_size) # 广播发射矩阵的分数
                    trans_score = self.transitions[next_tag].view(1, -1)    # 第i个time_step循环时, 转移到next_tag标签的转移概率
                    next_tag_var = forward_var + trans_score + emit_score   # 将前向矩阵, 转移矩阵, 发射矩阵累加
                    # 计算log_sum_exp()函数值,并添加到 alphas_t 列表中
                    log_sum_exp_result = log_sum_exp(next_tag_var).view(1) # 注意: log_sum_exp()函数仅仅返回一个实数值【log_sum_exp_result.view(1)的操作就是将一个数字变成一个一阶矩阵, ([]) -> ([1])】
                    alphas_t.append(log_sum_exp_result)
                alphas_t = torch.cat(alphas_t)
                forward_var = alphas_t.view(1, -1)  # 将列表张量转变为二维张量
            terminal_var = forward_var + self.transitions[self.tag_to_id[STOP_TAG]] # 添加最后一步转移到"STOP_TAG"的分数, 就完成了整条语句的分数计算
            alpha = log_sum_exp(terminal_var)   # 计算log_sum_exp()函数值, 作为一条样本语句的最终得分
            result[0][idx] = alpha  # 将得分添加到最终的结果列表中, 作为函数结果返回
            idx += 1
        return result


    # 损失函数第二项的分值函数
    def _score_sentence(self, features, tags): # features: 经历了_get_lstm_output_features()处理后的特征张量,形状为:[20, 8, 7] , tags代表训练语句真实的标签矩阵,形状: [8, 20]
        result = torch.zeros((1, self.batch_size))  # 初始化最终的结果分数张量, 每一个句子得到一个分数

        score = torch.zeros(1)  # 初始化一个0值的tensor, 为后续累加做准备
        temp = torch.full((self.batch_size, 1), self.tag_to_id[START_TAG]).clone().detach()
        tags = torch.cat((temp, tags), dim=1)   # 将START_TAG和真实标签tags做列维度上的拼接【在tags矩阵的第一列添加全部都是START_TAG的一列】
        features = features.transpose(1, 0)   # 将传入的features形状转变为[bathc_size, sentence_length, tag_size]

        idx = 0
        for feat_line in features:    # 按行遍历, 总共循环batch_size次【features:[8,20,7]; feat_line:[20,7]】
            for i, feat in enumerate(feat_line):    # 遍历一行语句, 每一个feat代表一个time_step(一个汉字或符号)【注意: 此处区别于第三步的循环, 最重要的是这是在真实标签指导下的转移矩阵和发射矩阵的累加分数】
                score = score + self.transitions[tags[idx][i + 1], tags[idx][i]] + feat[tags[idx][i + 1]]   # 将前向矩阵, 转移矩阵, 发射矩阵累加
            score = score + self.transitions[self.tag_to_id[STOP_TAG], tags[idx][-1]]   # 最遍历完当前语句所有的时间步之后,最后加上转移到STOP_TAG的分数
            result[0][idx] = score
            idx += 1
        return result


    # 根据传入的语句特征features, 根据维特比算法推断出标签序列【贪心算法、beam search算法、维特比算法都是在预测时使用的】
    def _viterbi_decode(self, features):
        result_best_path = []   # 初始化最佳路径结果的存放列表
        features = features.transpose(1, 0)   # 将输入张量变形为[batch_size, sequence_length, tag_size]
        for feat_line in features: # 对批次中的每一行语句进行遍历, 每个语句产生一个最优标注序列
            backpointers = []
            init_vvars = torch.full((1, self.tag_size), -10000.)    # 初始化前向传播的张量,
            init_vvars[0][self.tag_to_id[START_TAG]] = 0    # 设置START_TAG等于0, 约束合法序列只能从START_TAG开始
            forward_var = init_vvars    # 在第i个time_step, 张量forward_var保存第i-1个time_step的viterbi变量
            for feat in feat_line:  # 依次遍历i=0, 到序列最后的每一个time_step
                bptrs_t = []    # 保存当前time_step的回溯指针
                viterbivars_t = []  # 保存当前time_step的viterbi变量
                for next_tag in range(self.tag_size):
                    # next_tag_var[i]保存了tag_i 在前一个time_step的viterbi变量
                    next_tag_var = forward_var + self.transitions[next_tag] # 前向传播张量forward_var加上从tag_i转移到next_tag的分数, 赋值给next_tag_var【注意此处没有加发射矩阵分数, 因为求最大值不需要发射矩阵】
                    # 将最大的标签id加入到当前time_step的回溯列表中
                    best_tag_id = argmax(next_tag_var)
                    bptrs_t.append(best_tag_id)
                    viterbivars_t.append(next_tag_var[0][best_tag_id].view(1))
                forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1) # 此处再将发射矩阵分数feat加上, 赋值给forward_var, 作为下一个time_step的前向传播张量
                backpointers.append(bptrs_t)    # 当前time_step的回溯指针添加进当前这一行样本的总体回溯指针中
            terminal_var = forward_var + self.transitions[self.tag_to_id[STOP_TAG]] # 最后加上转移到STOP_TAG的分数
            best_tag_id = argmax(terminal_var)
            path_score = terminal_var[0][best_tag_id]   # path_score是整个路径的总得分
            # 根据回溯指针, 解码最佳路径
            best_path = [best_tag_id]   # 首先把最后一步的id值加入
            for bptrs_t in reversed(backpointers):  # 从后向前回溯最佳路径
                # 通过第i个time_step得到的最佳id, 找到第i-1个time_step的最佳id
                best_tag_id = bptrs_t[best_tag_id]
                best_path.append(best_tag_id)
            start = best_path.pop() # 将START_TAG删除
            assert start == self.tag_to_id[START_TAG]   # 确认一下最佳路径中的第一个标签是START_TAG
            best_path.reverse() # 因为是从后向前回溯, 所以再次逆序得到总前向后的真实路径
            result_best_path.append(best_path)  # 当前这一行的样本结果添加到最终的结果列表里
        return result_best_path

    # 对数似然函数的计算, 输入的是数字化编码后的语句, 和真实的标签【注意: 这个函数是真实“训练中”要用到的"虚拟化的forward()"】
    def neg_log_likelihood(self, sentence, tags):
        features = self._get_lstm_output_features(sentence)   # 第一步先得到BiLSTM层的输出特征张量【features : [20, 8, 7] 代表一个批次有8个样本, 每个样本长度20,每一个word映射到7个标签的概率, 发射矩阵】
        forward_score = self._forward_alg(features)    # forward_score 代表公式推导中损失函数loss的第一项
        print("forward_score = {0}".format(forward_score))
        gold_score = self._score_sentence(features, tags)  # gold_score 代表公式推导中损失函数loss的第二项
        print("gold_score = {0}".format(gold_score))
        return torch.sum(forward_score - gold_score, dim=1) # 按行求和, 在torch.sum()函数值中, 需要设置dim=1 ; 同理, dim=0代表按列求和【注意: 在这里, 通过forward_score和gold_score的差值来作为loss, 用来梯度下降训练模型】


    # 此处的forward()真实场景是用在预测部分, 训练的时候并没有用到【训练的时候用neg_log_likelihood】
    def forward(self, sentence):
        lstm_output_features = self._get_lstm_output_features(sentence)  # 获取从BiLSTM层得到的发射矩阵
        predict_tags_result = self._viterbi_decode(lstm_output_features)  # 通过维特比算法直接解码最佳路径
        return predict_tags_result



if __name__ == '__main__':
    sentence_list = [
        "确诊弥漫大b细胞淋巴瘤1年",
        "反复咳嗽、咳痰40年,再发伴气促5天。",
        "生长发育迟缓9年。",
        "右侧小细胞肺癌第三次化疗入院",
        "反复气促、心悸10年,加重伴胸痛3天。",
        "反复胸闷、心悸、气促2多月,加重3天",
        "咳嗽、胸闷1月余, 加重1周",
        "右上肢无力3年, 加重伴肌肉萎缩半年"
    ]
    # 真实标签数据, 对应为tag_to_id中的数字标签
    tag_list = [
        [0, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0],
        [0, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0],
        [0, 0, 3, 4, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [3, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 0, 0, 0, 0, 0],
        [0, 0, 1, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 3, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ]
    # 将标签转为标量tags
    tags = torch.tensor(tag_list, dtype=torch.long)
    char_to_id = {
    
    "<PAD>": 0}  # 初始化的码表 # 参数2:汉字与id对照码表
    SENTENCE_LENGTH = 20    # 参数3:句子长度
    tag_to_id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4, START_TAG: 5, STOP_TAG: 6}    # 参数4:标签码表对照
    EMBEDDING_DIM = 200 # 参数5:字向量维度
    HIDDEN_DIM = 100    # 参数6:隐层维度
    BATCH_SIZE = 8  # 参数7:批次大小
    NUM_LAYERS = 1  # 参数8:堆叠 LSTM 层数
    BATCH_FIRST = False

    # 1、构建汉字-序号对应码表
    char_to_id = char2id(char_to_id, sentence_list) # 创建char_to_id汉字与id对照码表
    # 2、根据char_to_id码表将字符串句子文本转为序列化表示
    sentence_sequence = sentence_map(sentence_list, char_to_id, SENTENCE_LENGTH)
    print("sentence_sequence.shpae = {0}".format(sentence_sequence.shape))
    print("sentence_sequence = \n{0}\n".format(sentence_sequence))
    # 3、实例化模型
    model = BiLSTM_CRF(vocab_size=len(char_to_id), tag_to_id=tag_to_id, embedding_size=EMBEDDING_DIM, hidden_size=HIDDEN_DIM, batch_size=BATCH_SIZE, sentence_length=SENTENCE_LENGTH, num_layers=NUM_LAYERS, batch_first=BATCH_FIRST)
    print("model: ", model)
    # 4、实例化损失函数优化器
    optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)
    # 5、训练
    for epoch in range(1):
        model.zero_grad()
        loss = model.neg_log_likelihood(sentence_sequence, tags)
        loss.backward()
        optimizer.step()
        print("loss = ", loss)
    # 6、预测
    predict_tags_result = model(sentence_sequence)
    print("predict_tags_result = \n{0}".format(predict_tags_result))

输出结果:

sentence_sequence.shpae = torch.Size([8, 20])

sentence_sequence = 
tensor([[14, 15, 16, 17, 18, 16, 19, 20, 21, 13, 22, 23, 24, 25, 26, 27, 28, 29, 30,  0],
        [14, 15, 26, 27, 18, 49, 50, 12, 21, 13, 22, 51, 52, 25, 53, 54, 55, 29, 30,  0],
        [14, 15, 53, 56, 18, 49, 50, 18, 26, 27, 57, 58, 59, 22, 51, 52, 55, 29, 0,  0],
        [37, 63, 64, 65, 66, 55, 13, 22, 61, 51, 52, 25, 67, 68, 69, 70, 71, 13, 0,  0],
        [37, 38, 39,  7,  8, 40, 41, 42, 43, 44, 45, 46, 47, 48,  0,  0,  0,  0, 0,  0],
        [16, 17, 18, 53, 56, 12, 59, 60, 22, 61, 51, 52, 12, 62,  0,  0,  0,  0, 0,  0],
        [ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13,  0,  0,  0,  0,  0, 0,  0],
        [31, 32, 24, 33, 34, 35, 36, 13, 30,  0,  0,  0,  0,  0,  0,  0,  0,  0, 0,  0]])

model:  BiLSTM_CRF(
  (word_embeds): Embedding(72, 200)
  (lstm): LSTM(200, 50, bidirectional=True)
  (hidden2tag): Linear(in_features=100, out_features=7, bias=True)
)

lstm_output_features.shape =  torch.Size([20, 8, 7])

forward_score = tensor([[ 47.1928,  93.1776, 139.0084, 184.5343, 231.0755, 276.7879, 322.5612, 367.0961]], grad_fn=<CopySlices>)

gold_score = tensor([[ 2.7348,  7.8746, 12.8035, 17.5623, 35.0289, 44.4671, 58.2662, 65.3670]], grad_fn=<CopySlices>)
loss =  tensor([1417.3292], grad_fn=<SumBackward1>)

lstm_output_features.shape =  torch.Size([20, 8, 7])

predict_tags_result = 
[
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]

Process finished with exit code 0

5 模型训练

模型训练的流程

  • 第一步: 熟悉字符到数字编码的码表
  • 第二步: 熟悉训练数据集的样式和含义解释
  • 第三步: 生成批量训练数据
  • 第四步: 完成准确率和召回率的评估代码
  • 第五步: 完成训练模型的代码
  • 第六步: 绘制损失曲线和评估曲线图

5.1 第一步: 字符到数字编码的码表【第三方库】

字符到数字编码的码表:代表了数据集中所有字符到数字编码的字典映射

码表可以包含中文简体、繁体、英文大小写字母、数字、中英文标点符号等等

<PAD>为填充标识, 训练时需要将句子转化成矩阵, 而句子长短不一, 需要做padding处理

{
    
    
    "<PAD>": 0,
    "厑": 1,
    "吖": 2,
    "呵": 3,
    "啊": 4,
    "嗄": 5,
    "嬶": 6,
    ...
}

码表所在位置: /data/doctor_offline/ner_model/data/char_to_id.json

5.2 第二步: 训练数据集的样式和含义解释

荨	B-dis
麻	I-dis
疹	I-dis
这	O
么	O
痒	O
咋	O
办	O
。	O

突	O
然	O
头	B-sym
晕	I-sym
呕	B-sym
吐	I-sym
。	O

训练数据集的含义解释:

  • 每一行包含一个字以及与之对应的标签, 字与标签之间通过\t分隔
  • 句子与句子之间通过空行分隔
  • 标签说明:
    • B-dis: 疾病实体名词起始标识
    • I-dis: 疾病实体名词中间到结尾标识
    • B-sym: 症状实体名词起始标识
    • I-sym: 症状实体名词中间到结尾标识
    • O: 其他非实体部分标识

数据集所在位置: /data/doctor_offline/ner_model/data/train.txt

将训练数据集转换为数字化编码集:

import json
import numpy as np

# 创建训练数据集, 从原始训练文件中将中文字符进行数字编码, 并将标签也进行数字编码
# char2id_json_file: 中文字符-id映射表json文件,tag2id: 标签-id映射表; train_data_file: 原始训练文件; result_file: 处理后的结果文件
def create_train_data(char2id_json_file, tag2id, train_data_file, result_file, max_length=20):
    char2id = json.load(open(char2id_json_file, mode='r', encoding='utf-8'))    # 导入json格式的中文字符到id的映射表
    char_data, tag_data = [], []
    with open(train_data_file, mode='r', encoding='utf-8') as f:    # 打开原始训练文件
        # 初始化一条语句(字符串句子)数字化编码后的列表
        char_ids = [0] * max_length
        tag_ids = [0] * max_length
        idx = 0
        for line in f.readlines():  # line格式【char \t tag,比如:荨	\t B-dis】
            line = line.strip('\n').strip()
            if len(line) > 0 and line and idx < max_length: # 如果不是空行, 并且当前语句长度没有超过max_length, 则进行字符到id的映射
                ch, tag = line.split('\t')
                if char2id.get(ch): # 如果当前字符存在于映射表中, 则直接映射为对应的id值
                    char_ids[idx] = char2id[ch]
                else:   # 否则直接用"UNK"的id值来代替这个未知字符
                    char_ids[idx] = char2id['UNK']
                tag_ids[idx] = tag2id[tag]  # 将标签也进行对应的转换
                idx += 1
            else:   # 如果是空行, 或者当前语句长度达到max_length
                if idx <= max_length:   # 如果当前语句长度小于或达到max_length, 直接将char_ids已经添加的的部分作为结果
                    char_data.append(char_ids)
                    tag_data.append(tag_ids)
                char_ids = [0] * max_length # 当前句子已经结束, 初始化清零, 为下一个句子的映射做准备
                tag_ids = [0] * max_length  # 当前句子已经结束, 初始化清零, 为下一个句子的映射做准备
                idx = 0  # 当前句子已经结束, 初始化清零, 为下一个句子的映射做准备
    x_data = np.array(char_data, dtype=np.int32)    # 将数字化编码后的数据封装成numpy的数组类型, 数字编码采用np.int32
    y_data = np.array(tag_data, dtype=np.int32)     # 将数字化编码后的数据封装成numpy的数组类型, 数字编码采用np.int32
    print("x_data.shape = {0}".format(x_data.shape))
    print("x_data[0] = {0}".format(x_data[0]))
    print("y_data.shape = {0}".format(y_data.shape))
    print("y_data[0] = {0}".format(y_data[0]))
    np.savez(result_file, x_data=x_data, y_data=y_data)  # 直接利用np.savez()将数据存储为.npz类型的压缩式文件
    print("create_train_data Finished!".center(100, "-"))


if __name__ == '__main__':
    char2id_json_file = './data/char_to_id.json'    # 参数1:字符码表文件路
    tag2id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4, "<START>": 5, "<STOP>": 6}    # 参数2:标签码表对照字典
    train_data_file = './data/train.txt'    # 参数3:训练数据文件路径
    result_file = './data/train.npz'    # 参数4:创建的npz文件保路径(训练数据)
    create_train_data(char2id_json_file, tag2id, train_data_file, result_file)

输出结果:

x_data.shape = (13366, 20)
x_data[0] = [13048 10974  1884 13048 10974  1884 19924 10920 18146 19502   364 21252     0     0     0     0     0     0     0     0]
y_data.shape = (13366, 20)
y_data[0] = [1 2 2 1 2 2 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
------------------------------------create_train_data Finished!-------------------------------------

Process finished with exit code 0

生成了新的数据集文件: /data/doctor_offline/ner_model/data/train.npz

5.3 第三步: 生成批量训练数据

import numpy as np
import torch
import torch.utils.data as Data

# 生成批量训练数据
def load_dataset(data_file, batch_size):    # data_file: 待处理的文件; batch_size: 代表每一个批次样本的数量
    data = np.load(data_file)   # 将train.npz文件导入内存
    x_data = data['x_data'] # 取出特征值
    y_data = data['y_data'] # 取出标签值
    x = torch.tensor(x_data, dtype=torch.long)  # 将数据封装成tensor张量
    y = torch.tensor(y_data, dtype=torch.long)  # 将数据封装成tensor张量
    dataset = Data.TensorDataset(x, y)  # 将数据封装成Tensor数据集
    total_length = len(dataset) # dataset数据总量
    train_length = int(total_length * 0.8)  # 采用80%的数据作为训练集, 20%的数据作为测试集
    validation_length = total_length - train_length
    train_dataset, validation_dataset = Data.random_split(dataset=dataset, lengths=[train_length, validation_length])   # 利用Data.random_split()直接切分集合, 按照80%, 20%的比例划分
    # 将训练集进行DataLoader封装
    # 参数说明如下:
    # dataset:     训练数据集
    # batch_size:  代表批次大小, 若数据集总样本数量无法被batch_size整除, 则最后一批数据为余数,若设置drop_last为True, 则自动抹去最后不能被整除的剩余批次
    # shuffle:     是否每个批次为随机抽取, 若为True, 则每次迭代时数据为随机抽取
    # num_workers: 设定有多少子进程用来做数据加载, 默认为0, 即数据将被加载到主进程中
    # drop_last:   是否去除不能被整除后的最后批次, 若为True, 则不生成最后不能被整除剩余的数据内容【例如: dataset长度为1028, batch_size为8, 若drop_last=True, 则最后剩余的4(1028/8=128余4)条数据将被抛弃不用
    train_loader = Data.DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True, num_workers=4, drop_last=True)
    validation_loader = Data.DataLoader(dataset=validation_dataset, batch_size=batch_size, shuffle=True, num_workers=4, drop_last=True)
    data_loaders = {
    
    'train': train_loader, 'validation': validation_loader} # 将两个数据生成器封装为一个字典类型
    data_size = {
    
    'train': train_length, 'validation': validation_length}    # 将两个数据集的长度也封装为一个字典类型
    return data_loaders, data_size



if __name__ == '__main__':
    DATA_FILE = './data/train.npz'  # 编码后的训练数据文件路径
    BATCH_SIZE = 8  # 批次大小

    data_loader, data_size = load_dataset(data_file=DATA_FILE, batch_size=BATCH_SIZE)
    print('data_loader:', data_loader, '\ndata_size:', data_size)

输出结果:

data_loader: {
    
    'train': <torch.utils.data.dataloader.DataLoader object at 0x7feaf6c02208>, 'validation': <torch.utils.data.dataloader.DataLoader object at 0x7feaf6c0bd68>} 
data_size: {
    
    'train': 10692, 'validation': 2674}

Process finished with exit code 0

代码实现位置: /data/doctor_offline/ner_model/loader_data.py

5.4 第四步: 完成准确率和召回率的评估代码

import numpy as np
import torch

# 工具函数:本函数实现将中文文本映射为数字化的张量【将句子中的每一个字符映射到码表中, 返回每一个字对应的编码, 类型为tensor】
def sentence_map(sentence_list, char_to_id, max_length):    # sentence: 待映射句子, 类型为字符串或列表; char_to_id: 完整版码表, 类型为字典, 格式为{"字1": 1, "字2": 2};
    sentence_list.sort(key=lambda c:len(c), reverse=True)   # 字符串按照逆序进行排序, 不是必须操作
    sentence_map_list = []  # 定义句子映射列表
    for sentence in sentence_list:
        sentence_id_list = [char_to_id[c] for c in sentence]    # 生成句子中每个字对应的 id 列表【序列化的句子】
        padding_list = [0] * (max_length-len(sentence)) # 计算所要填充 0 的长度
        sentence_id_list.extend(padding_list)   # 组合
        sentence_map_list.append(sentence_id_list)  # 将填充后的列表加入句子映射总表中【序列化的句子列表】
    return torch.tensor(sentence_map_list, dtype=torch.long)    # 返回句子映射集合, 转为张量【返回:序列化的句子列表】

# 工具函数:创建字符-序号映射字典
def char2id(char_to_id, sentence_list):
    for sentence in sentence_list:
        for _char in sentence:
            if _char not in char_to_id:
                char_to_id[_char] = len(char_to_id)
    return char_to_id

# 评估模型的准确率, 召回率, F1, 等指标
# sentence_list: 文本向量化后的句子向量列表; true_tag_list: 真实的标签; predict_tag_list: 模型预测的标签; id2char: id值到中文字符的映射表; id2tag: id值到标签的映射表
def evaluate(sentence_list, true_tag_list, predict_tag_list, id2char, id2tag):
    # 初始化真实的命名实体, 预测的命名实体, 接下来比较两者来评估各项指标
    target_entities, true_entity = [], []
    predict_entities, predict_entity = [], []
    for line_num, sentence in enumerate(sentence_list): # 逐条遍历批次中所有的语句
        for char_num in range(len(sentence)):   # 遍历一条样本语句中的每一个字符编码(这里面是数字化编码)
            if sentence[char_num] == 0: # 编码为0, 表示后面都是填充的0, 可以结束for循环
                break
            char_text = id2char[sentence[char_num]] # 真实的样本字符
            true_tag_type = id2tag[true_tag_list[line_num][char_num]]    #  真实的标签
            predict_tag_type = id2tag[predict_tag_list[line_num][char_num]]  # 预测的标签

            # 对真实标签进行命名实体的匹配
            if true_tag_type[0] == "B": # 如果第一个字符是"B", 表示一个实体的开始, 将"字符/标签"的格式添加进实体列表中
                true_entity = [char_text + "/" + true_tag_type]
            # 如果第一个字符是"I", 表示处于一个实体的中间
            # 如果真实命名实体列表非空, 并且最后一个添加进去的标签类型和当前的标签类型一样, 则继续添加【意思就是比如true_entity = ["中/B-Person", "国/I-Person"], 此时的"人/I-Person"就可以添加进去, 因为都属于同一个命名实体】
            elif true_tag_type[0] == "I" and len(true_entity) != 0 and true_entity[-1].split("/")[1][1:] == true_tag_type[1:]:
                true_entity.append(char_text + "/" + true_tag_type)
            elif true_tag_type[0] == "O" and len(true_entity) != 0: # 如果第一个字符是"O", 并且true_entity非空, 表示一个命名实体的匹配结束了
                true_entity.append(str(line_num) + "_" + str(char_num)) # 最后增加进去一个"行号_列号", 作为区分实体的标志
                target_entities.append(true_entity)   # 将这个匹配出来的实体加入到结果列表中
                true_entity = []    # 清空true_entity, 为下一个命名实体的匹配做准备
            else:   # 除了上面三种情况, 说明当前没有匹配出任何命名实体, 则清空true_entity, 继续下一次匹配
                true_entity = []

            # 对预测标签进行命名实体的匹配
            if predict_tag_type[0] == "B":  # 如果第一个字符是"B", 表示一个实体的开始, 将"字符/预测标签"的格式添加进实体列表中
                predict_entity = [char_text + "/" + predict_tag_type]
            # 如果第一个字符是"I", 表示处于一个实体的中间
            # 如果预测命名实体列表非空, 并且最后一个添加进去的标签类型和当前的标签类型一样, 则继续添加【意思就是比如predict_entity = ["中/B-Person", "国/I-Person"], 此时的"人/I-Person"就可以添>加进去, 因为都属于同一个命名实体】
            elif predict_tag_type[0] == "I" and len(predict_entity) != 0 and predict_entity[-1].split("/")[1][1:] == predict_tag_type[1:]:
                predict_entity.append(char_text + "/" + predict_tag_type)
            elif predict_tag_type[0] == "O" and len(predict_entity) != 0:   # 如果第一个字符是"O", 并且predict_entity非空, 表示一个命名实体的匹配结束了
                predict_entity.append(str(line_num) + "_" + str(char_num))  # 最后增加进去一个"行号_列号", 作为区分实体的标志
                predict_entities.append(predict_entity) # 将这个匹配出来的实体加入到结果列表中
                predict_entity = [] # 清空predict_entity, 为下一个命名实体的匹配做准备
            else:   # 除了上面三种情况, 说明当前没有匹配出任何命名实体, 则清空predict_entity, 继续下一次匹配
                predict_entity = []

    acc_entities = [entity for entity in predict_entities if entity in target_entities]   # 遍历所有预测实体的列表, 只有那些在真实命名实体中的才是正确的
    acc_entities_size = len(acc_entities) # 正确实体的个数
    predict_entities_size = len(predict_entities) # 预测实体的总个数
    target_entities_size = len(target_entities)   # 真实实体的总个数

    if acc_entities_size > 0: # 至少正确预测了一个, 才计算3个指标, 准确率
        accuracy = float(acc_entities_size / predict_entities_size)
        recall = float(acc_entities_size / target_entities_size)
        f1_score = 2 * accuracy * recall / (accuracy + recall)
        return accuracy, recall, f1_score, acc_entities_size, predict_entities_size, target_entities_size
    else:
        return 0, 0, 0, acc_entities_size, predict_entities_size, target_entities_size


# 测试
if __name__ == '__main__':
    SENTENCE_LENGTH = 20
    # 编码与字符对照字典
    id2char = {
    
    0: '<PAD>', 1: '确', 2: '诊', 3: '弥', 4: '漫', 5: '大', 6: 'b', 7: '细', 8: '胞', 9: '淋', 10: '巴', 11: '瘤', 12: '1', 13: '年', 14: '反', 15: '复', 16: '咳', 17: '嗽', 18: '、', 19: '痰', 20: '4', 21: '0', 22: ',', 23: '再', 24: '发', 25: '伴', 26: '气', 27: '促', 28: '5', 29: '天', 30: '。', 31: '生', 32: '长', 33: '育', 34: '迟', 35: '缓', 36: '9', 37: '右', 38: '侧', 39: '小', 40: '肺', 41: '癌', 42: '第', 43: '三', 44: '次', 45: '化', 46: '疗', 47: '入', 48: '院', 49: '心', 50: '悸', 51: '加', 52: '重', 53: '胸', 54: '痛', 55: '3', 56: '闷', 57: '2', 58: '多', 59: '月', 60: '余', 61: ' ', 62: '周', 63: '上', 64: '肢', 65: '无', 66: '力', 67: '肌', 68: '肉', 69: '萎', 70: '缩', 71: '半'}
    # 编码与标签对照字典
    id2tag = {
    
    0: 'O', 1: 'B-dis', 2: 'I-dis', 3: 'B-sym', 4: 'I-sym'}
    # 真实标签数据
    true_tag_list = [
        [0, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0],
        [0, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0],
        [0, 0, 3, 4, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [3, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 0, 0, 0, 0],
        [0, 0, 1, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 3, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ]
    # 预测标签数据
    predict_tag_list = [
        [0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0],
        [0, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0],
        [0, 0, 3, 4, 0, 3, 4, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [3, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 0, 0, 0, 0],
        [0, 0, 1, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 3, 4, 0, 0, 0, 0, 0],
        [3, 4, 0, 3, 4, 0, 0, 1, 2, 0, 0, 3, 4, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 3, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ]

    # 输入的数字化sentences_sequence, 由下面的sentence_list经过映射函数sentence_map()转化后得到
    sentence_list = [
        "确诊弥漫大b细胞淋巴瘤1年",
        "反复咳嗽、咳痰40年,再发伴气促5天。",
        "生长发育迟缓9年。",
        "右侧小细胞肺癌第三次化疗入院",
        "反复气促、心悸10年,加重伴胸痛3天。",
        "反复胸闷、心悸、气促2多月,加重3天",
        "咳嗽、胸闷1月余, 加重1周",
        "右上肢无力3年, 加重伴肌肉萎缩半年"
    ]
    char_to_id = {
    
    "<PAD>": 0}   # 初始化汉字-序号对应码表
    char_to_id = char2id(char_to_id,sentence_list)  # 构建汉字-序号对应码表
    sentences_sequence = sentence_map(sentence_list, char_to_id, SENTENCE_LENGTH)   # 根据char_to_id码表将字符串句子文本转为序列化表示
    # 评估模型的准确率, 召回率, F1
    print("sentences_sequence.shape = {0}".format(torch.tensor(np.array(sentences_sequence)).shape))
    print("true_tag_list.shape = {0}".format(torch.tensor(np.array(true_tag_list)).shape))
    print("predict_tag_list.shape = {0}".format(torch.tensor(np.array(predict_tag_list)).shape))
    accuracy, recall, f1_score, acc_entities_size, predict_entities_size, target_entities_size = evaluate(sentences_sequence.tolist(), true_tag_list, predict_tag_list, id2char, id2tag)

    print("accuracy:", accuracy,
          "\nrecall:", recall,
          "\nf1_score:", f1_score,
          "\nacc_entities_size:", acc_entities_size,
          "\npredict_entities_size:", predict_entities_size,
          "\ntarget_entities_size:", target_entities_size)

输出结果:

sentences_sequence.shape = torch.Size([8, 20])
true_tag_list.shape = torch.Size([8, 20])
predict_tag_list.shape = torch.Size([8, 20])
accuracy: 0.8823529411764706 
recall: 0.9375 
f1_score: 0.9090909090909091 
acc_entities_size: 15 
predict_entities_size: 17 
target_entities_size: 16

Process finished with exit code 0

代码实现位置: /data/doctor_offline/ner_model/evaluate_model.py

5.5 第五步: 训练模型的代码

import json
import time
from tqdm import tqdm
import matplotlib.pyplot as plt
import torch
import torch.optim as optim
from torch.autograd import Variable
# 导入之前编写好的包, 包括类, 数据集加载, 评估函数
from bilstm_crf import BiLSTM_CRF
from loader_data import load_dataset
from evaluate_model import evaluate

'''
BATCH_SIZE:  批次的样本个数
data_loader: 数据集的加载器, 之前已经通过load_dataset完成了构造
data_size:   训练集和测试集的样本数量
EMBEDDING_SIZE:  词嵌入的维度
HIDDEN_SIZE:     隐藏层的维度
SENTENCE_LENGTH:  文本限制的长度
NUM_LAYERS:       神经网络堆叠的LSTM层数
EPOCHS:           训练迭代的轮次
LEARNING_RATE:    学习率
tag2id:           标签到id的映射字典
model_saved_path: 模型保存的路径
train_log_path:   训练日志保存的路径
validate_log_path:  测试集日志保存的路径
train_history_image_path:  训练数据的相关图片保存路径
'''
# 一、初始化参数
BATCH_SIZE = 8  # 参数1:批次大小
train_data_file_path = "data/train.npz"  # 参数2:训练数据文件路径
data_loader, data_size = load_dataset(train_data_file_path, BATCH_SIZE)  # 参数3:加载 DataLoader 数据
EMBEDDING_SIZE = 200  # 参数10:字向量维度
HIDDEN_SIZE = 100  # 参数11:隐层维度
SENTENCE_LENGTH = 20  # 参数12:句子长度
NUM_LAYERS = 1  # 参数13:堆叠 LSTM 层数
EPOCHS = 10  # 参数14:训练批次
LEARNING_RATE = 0.5  # 参数15:初始化学习率
tag2id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4, "<START>": 5, "<STOP>": 6}  # 参数5:标签码表对照
id2tag = {
    
    v: k for k, v in tag2id.items()}  # 利用tag2id生成id到tag的映射字典
time_str = time.strftime("%Y%m%d_%H%M%S", time.localtime(time.time()))  # 参数4:记录当前训练时间(拼成字符串用)
model_saved_path = "saved_model/bilstm_crf_state_dict_%s.pt" % (time_str)  # 参数6:训练文件存放路径
train_log_path = "log/train_%s.log" % (time_str)  # 参数7:训练日志文件存放路径
validate_log_path = "log/validate_%s.log" % (time_str)  # 参数8:验证打印日志存放路径
train_history_image_path = "log/bilstm_crf_train_plot_%s.png" % (time_str)  # 参数9:训练历史记录图存放路径

# 二、将中文字符和id的对应码表加载进内存
char2id = json.load(open("./data/char_to_id.json", mode="r", encoding="utf-8"))
id2char = {
    
    v: k for k, v in char2id.items()}  # 利用char2id生成id到字符的映射字典

# 三、实例化BiLSTM_CRF模型
model = BiLSTM_CRF(vocab_size=len(char2id), tag2id=tag2id, embedding_size=EMBEDDING_SIZE, hidden_size=HIDDEN_SIZE, batch_size=BATCH_SIZE, num_layers=NUM_LAYERS, sentence_length=SENTENCE_LENGTH)

# 四、实例化优化器,使用SGD作为优化器(pytorch中Embedding支持的GPU加速为SGD, SparseAdam)【lr: 优化器学习率; momentum: 优化下降的动量因子, 加速梯度下降过程】
optimizer = optim.SGD(params=model.parameters(), lr=LEARNING_RATE, momentum=0.85)

# 五、实例化优化器学习率更新策略【optimizer: 优化器; step_size: 更新频率, 每过多少个epoch更新一次优化器学习率; gamma: 学习率衰减幅度, 按照什么比例调整(衰减)学习率(相对于上一轮epoch), 默认0.1】
#   初始学习率 lr = 0.5,    step_size = 20,    gamma = 0.1
#              lr = 0.5     if epoch < 20
#              lr = 0.05    if 20 <= epoch < 40
#              lr = 0.005   if 40 <= epoch < 60
scheduler = optim.lr_scheduler.StepLR(optimizer=optimizer, step_size=5, gamma=0.2)

# 六、初始化存放训练时的损失, 准确率, 召回率, F1等数值指标
train_loss_list = []
train_acc_list = []
train_recall_list = []
train_f1_list = []
train_log_file = open(train_log_path, mode="w", encoding="utf-8")

# 七、初始化存放测试时的损失, 准确率, 召回率, F1等数值指标
validate_loss_list = []
validate_acc_list = []
validate_recall_list = []
validate_f1_list = []
validate_log_file = open(validate_log_path, mode="w", encoding="utf-8")


# 八、训练一个epoch
def train_epoch(epoch):
    total_acc_entities_size, total_predict_entities_size, total_target_entities_size = 0, 0, 0  # 定义要记录的模型识别正确的总实体数, 模型已经识别的总实体数、实际上真实的总实体数
    total_loss, correct, f1 = 0, 0, 0  # 定义每batch步数, 批次loss总值, 准确度, f1值
    for batch_index, (inputs, labels) in enumerate(tqdm(data_loader["train"])):   # 开启当前epochs的训练部分
        inputs, labels = Variable(inputs), Variable(labels) # 将数据以Variable进行封装
        optimizer.zero_grad()   # 在训练模型期间, 要在每个样本计算梯度前将优化器归零, 不然梯度会被累加
        loss = model.neg_log_likelihood(inputs, labels) # 此处调用的是BiLSTM_CRF类中的neg_log_likelihood()函数
        step_loss = loss.data   # 获取当前步的loss, 由tensor转为数字
        total_loss += step_loss # 累计每步损失值
        best_path_list = model(inputs)  # 获取解码最佳路径列表, 此时调用的是BiLSTM_CRF类中使用了维特比算法的forward()函数
        # 模型评估【为了实时查看模型训练的效果】指标值获取包括:当前批次准确率, 召回率, F1值以及对应的实体个数
        step_acc, step_recall, f1_score, acc_entities_size, predict_entities_size, target_entities_size = evaluate(inputs.tolist(), labels.tolist(), best_path_list, id2char, id2tag)
        # 训练日志内容
        log_text = "Epoch: %s | batch_index: %s | loss: %.5f | acc: %.5f | recall: %.5f | f1 score: %.5f" %  (epoch, batch_index, step_loss, step_acc, step_recall, f1_score)
        # 分别累计正确总实体数、识别实体数以及真实实体数
        total_acc_entities_size += acc_entities_size
        total_predict_entities_size += predict_entities_size
        total_target_entities_size += target_entities_size
        loss.backward() # 对损失函数进行反向传播
        optimizer.step()    # 通过optimizer.step()计算损失, 梯度和更新参数
        train_log_file.write(log_text + "\n")   # 记录训练日志
    epoch_loss = total_loss / data_size["train"]    # 获取当前epochs平均损失值(每一轮迭代的损失总值除以总数据量)
    total_acc = total_acc_entities_size / total_predict_entities_size   # 计算当前epochs准确率
    total_recall = total_acc_entities_size / total_target_entities_size   # 计算当前epochs召回率
    # 计算当前epochs的F1值
    total_f1 = 0
    if total_acc + total_recall != 0:
        total_f1 = 2 * total_acc * total_recall / (total_acc + total_recall)
    log_text = "Epoch: %s | mean loss: %.5f | total acc: %.5f | total recall: %.5f | total f1 scroe: %.5f" % (epoch, epoch_loss, total_acc, total_recall, total_f1)
    scheduler.step()    # 当前epochs训练后更新学习率, 必须在优化器更新之后
    # 记录当前epochs训练loss值(用于图表展示), 准确率, 召回率, f1值
    train_loss_list.append(epoch_loss)
    train_acc_list.append(total_acc)
    train_recall_list.append(total_recall)
    train_f1_list.append(total_f1)
    train_log_file.write(log_text + "\n")

# 九、验证一个epoch
def validate_epoch(epoch):
    total_acc_entities_size, total_predict_entities_size, total_target_entities_size = 0, 0, 0  # 定义要记录的模型识别正确的总实体数, 模型已经识别的总实体数、实际上真实的总实体数
    total_loss, correct, f1 = 0, 0, 0    # 定义每batch步数, 批次loss总值, 准确度, f1值
    for batch_index, (inputs, labels) in enumerate(tqdm(data_loader["validation"])):
        inputs, labels = Variable(inputs), Variable(labels) # 将数据以Variable进行封装
        loss = model.neg_log_likelihood(inputs, labels) # 此处调用的是BiLSTM_CRF类中的neg_log_likelihood 函数【返回最终的CRF的对数似然结果】
        step_loss = loss.data   # 获取当前步的loss值, 由tensor转为数字
        total_loss += step_loss # 累计每步损失值
        best_path_list = model(inputs)  # 获取解码最佳路径列表, 此时调用的是BiLSTM_CRF类中使用了维特比算法的forward()函数
        # 模型评估【查看已训练模型的效果】指标值获取: 当前批次准确率, 召回率, F1值以及对应的实体个数
        step_acc, step_recall, f1_score, acc_entities_size, predict_entities_size, target_entities_size = evaluate(inputs.tolist(), labels.tolist(), best_path_list, id2char, id2tag)
        # 测试日志内容
        log_text = "Epoch: %s | batch_index: %s | loss: %.5f | acc: %.5f | recall: %.5f | f1 score: %.5f" %  (epoch, batch_index, step_loss, step_acc, step_recall, f1_score)
        # 分别累计正确总实体数、识别实体数以及真实实体数
        total_acc_entities_size += acc_entities_size
        total_predict_entities_size += predict_entities_size
        total_target_entities_size += target_entities_size
        validate_log_file.write(log_text + "\n")    # 记录验证集损失日志
    epoch_loss = total_loss / data_size["validation"]   # 获取当前批次平均损失值(每一批次损失总值除以数据量)
    total_acc = total_acc_entities_size / total_predict_entities_size   # 计算总批次准确率
    total_recall = total_acc_entities_size / total_target_entities_size   # 计算总批次召回率
    # 计算总批次F1值
    total_f1 = 0
    if total_acc + total_recall != 0:
        total_f1 = 2 * total_acc * total_recall / (total_acc + total_recall)
    log_text = "Epoch: %s | mean loss: %.5f | total acc: %.5f | total recall: %.5f | total f1 scroe: %.5f" % (epoch, epoch_loss, total_acc, total_recall, total_f1)
    # 记录当前批次验证loss值(用于图表展示)准确率, 召回率, f1值
    validate_loss_list.append(epoch_loss)
    validate_acc_list.append(total_acc)
    validate_recall_list.append(total_recall)
    validate_f1_list.append(total_f1)
    validate_log_file.write(log_text + "\n")


# 十、按照传入的不同路径, 绘制不同的训练曲线
def draw_train_history_image(train_history_list, validate_history_list, history_image_path, data_type):
    plt.plot(train_history_list, label="Train %s History" % (data_type))    # 根据训练集的数据列表, 绘制折线图
    plt.plot(validate_history_list, label="Validate %s History" % (data_type))  # 根据测试集的数据列表, 绘制折线图
    plt.legend(loc="best")  # 将图片放置在最优位置
    plt.xlabel("Epochs")    # 设置x轴的图标为轮次Epochs
    plt.ylabel(data_type)   # 设置y轴的图标为参数data_type
    plt.savefig(history_image_path.replace("plot", data_type))  # 将绘制好的图片保存在特定的路径下面, 并修改图片名字中的"plot"为对应的data_type
    plt.close()

if __name__ == '__main__':
    for epoch in range(EPOCHS): # 按照参数epochs的设定来循环epochs次
        tqdm.write("Epoch {}/{}".format(epoch + 1, EPOCHS))  # 在进度条打印前, 先输出当前所执行批次
        print("=" * 50, "epoch = {0}, 开始训练".format(epoch), "=" * 50)
        train_epoch(epoch)
        print("=" * 50, "epoch = {0}, 开始验证".format(epoch), "=" * 50)
        validate_epoch(epoch)
        torch.save(model.state_dict(), model_saved_path)  # 保存模型
        print("train_loss_list = ", train_loss_list)
        print("validate_loss_list = ", validate_loss_list)
        print("train_acc_list = ", train_acc_list)
        print("validate_acc_list = ", validate_acc_list)
        print("train_recall_list = ", train_recall_list)
        print("validate_recall_list = ", validate_recall_list)
        print("train_f1_list = ", train_f1_list)
        print("validate_f1_list = ", validate_f1_list)
        draw_train_history_image(train_loss_list, validate_loss_list,  train_history_image_path, "Loss")    # 将loss下降历史数据转为图片存储
        draw_train_history_image(train_acc_list, validate_acc_list, train_history_image_path, "Acc")    # 将准确率提升历史数据转为图片存储
        draw_train_history_image(train_recall_list, validate_recall_list, train_history_image_path, "Recall")   # 将召回率提升历史数据转为图片存储
        draw_train_history_image(train_f1_list, validate_f1_list, train_history_image_path, "F1")   # 将F1上升历史数据转为图片存储
        print("train Finished".center(100, "-"))

代码实现位置: /data/doctor_offline/ner_model/train.py

输出效果:

  • 模型训练结果文件保存位置:model/bilstm_crf_state_dict_[年月日时分秒时间字符串].pt
  • 模型训练日志文件保存位置:log/train_[年月日时分秒时间字符串].log
  • 模型验证日志文件保存位置:log/validate_[年月日时分秒时间字符串].log
  • 模型训练损失历史记录图片保存位置:log/bilstm_crf_train_Loss_[年月日时分秒时间字符串].png
  • 模型训练准确率历史记录图片保存位置:log/bilstm_crf_train_Acc_[年月日时分秒时间字符串].png
  • 模型训练召回率历史记录图片保存位置:log/bilstm_crf_train_Recall_[年月日时分秒时间字符串].png
  • 模型训练F1值历史记录图片保存位置:log/bilstm_crf_train_F1_[年月日时分秒时间字符串].png

训练日志:

Epoch: 0 | train loss: 366.58832 |acc: 0.632 |recall: 0.503 |f1 score: 0.56 | validate loss: 666.032 |acc: 0.591 |recall: 0.457 |f1 score: 0.515
Epoch: 1 | train loss: 123.87159 |acc: 0.743 |recall: 0.687 |f1 score: 0.714 | validate loss: 185.021 |acc: 0.669 |recall: 0.606 |f1 score: 0.636
Epoch: 2 | train loss: 113.04003 |acc: 0.738 |recall: 0.706 |f1 score: 0.722 | validate loss: 107.393 |acc: 0.711 |recall: 0.663 |f1 score: 0.686
Epoch: 3 | train loss: 119.14317 |acc: 0.751 |recall: 0.692 |f1 score: 0.721 | validate loss: 158.381 |acc: 0.713 |recall: 0.64 |f1 score: 0.674
Epoch: 4 | train loss: 105.81506 |acc: 0.741 |recall: 0.699 |f1 score: 0.72 | validate loss: 118.99 |acc: 0.669 |recall: 0.624 |f1 score: 0.646
Epoch: 5 | train loss: 86.67545 |acc: 0.773 |recall: 0.751 |f1 score: 0.762 | validate loss: 123.636 |acc: 0.64 |recall: 0.718 |f1 score: 0.676
Epoch: 6 | train loss: 79.66924 |acc: 0.808 |recall: 0.772 |f1 score: 0.789 | validate loss: 89.771 |acc: 0.735 |recall: 0.714 |f1 score: 0.724
Epoch: 7 | train loss: 85.35771 |acc: 0.766 |recall: 0.752 |f1 score: 0.759 | validate loss: 141.233 |acc: 0.675 |recall: 0.7 |f1 score: 0.687
Epoch: 8 | train loss: 82.38535 |acc: 0.787 |recall: 0.748 |f1 score: 0.767 | validate loss: 108.429 |acc: 0.717 |recall: 0.673 |f1 score: 0.694
Epoch: 9 | train loss: 82.46296 |acc: 0.783 |recall: 0.751 |f1 score: 0.767 | validate loss: 74.716 |acc: 0.692 |recall: 0.702 |f1 score: 0.697
Epoch: 10 | train loss: 75.12292 |acc: 0.814 |recall: 0.779 |f1 score: 0.796 | validate loss: 90.693 |acc: 0.672 |recall: 0.7 |f1 score: 0.686
Epoch: 11 | train loss: 74.89426 |acc: 0.813 |recall: 0.77 |f1 score: 0.791 | validate loss: 77.161 |acc: 0.729 |recall: 0.718 |f1 score: 0.724
Epoch: 12 | train loss: 76.39055 |acc: 0.814 |recall: 0.785 |f1 score: 0.799 | validate loss: 132.545 |acc: 0.806 |recall: 0.685 |f1 score: 0.74
Epoch: 13 | train loss: 75.01093 |acc: 0.814 |recall: 0.787 |f1 score: 0.8 | validate loss: 101.596 |acc: 0.765 |recall: 0.681 |f1 score: 0.721
Epoch: 14 | train loss: 74.35796 |acc: 0.83 |recall: 0.802 |f1 score: 0.816 | validate loss: 92.535 |acc: 0.745 |recall: 0.777 |f1 score: 0.761
Epoch: 15 | train loss: 73.27102 |acc: 0.818 |recall: 0.791 |f1 score: 0.804 | validate loss: 109.51 |acc: 0.68 |recall: 0.76 |f1 score: 0.717
Epoch: 16 | train loss: 67.66725 |acc: 0.841 |recall: 0.811 |f1 score: 0.826 | validate loss: 93.047 |acc: 0.768 |recall: 0.738 |f1 score: 0.753
Epoch: 17 | train loss: 63.75809 |acc: 0.83 |recall: 0.813 |f1 score: 0.822 | validate loss: 76.231 |acc: 0.784 |recall: 0.776 |f1 score: 0.78
Epoch: 18 | train loss: 60.30417 |acc: 0.845 |recall: 0.829 |f1 score: 0.837 | validate loss: 76.019 |acc: 0.806 |recall: 0.758 |f1 score: 0.781
Epoch: 19 | train loss: 60.30238 |acc: 0.849 |recall: 0.823 |f1 score: 0.836 | validate loss: 90.269 |acc: 0.748 |recall: 0.733 |f1 score: 0.741
Epoch: 20 | train loss: 60.20072 |acc: 0.847 |recall: 0.82 |f1 score: 0.833 | validate loss: 61.756 |acc: 0.81 |recall: 0.77 |f1 score: 0.79
Epoch: 21 | train loss: 58.98606 |acc: 0.844 |recall: 0.82 |f1 score: 0.832 | validate loss: 60.799 |acc: 0.765 |recall: 0.754 |f1 score: 0.759
Epoch: 22 | train loss: 60.23671 |acc: 0.848 |recall: 0.828 |f1 score: 0.838 | validate loss: 65.676 |acc: 0.787 |recall: 0.781 |f1 score: 0.784
Epoch: 23 | train loss: 58.57862 |acc: 0.849 |recall: 0.827 |f1 score: 0.838 | validate loss: 65.975 |acc: 0.794 |recall: 0.754 |f1 score: 0.774
Epoch: 24 | train loss: 58.93968 |acc: 0.848 |recall: 0.827 |f1 score: 0.838 | validate loss: 66.994 |acc: 0.784 |recall: 0.746 |f1 score: 0.764
Epoch: 25 | train loss: 59.91834 |acc: 0.862 |recall: 0.828 |f1 score: 0.845 | validate loss: 68.794 |acc: 0.795 |recall: 0.756 |f1 score: 0.775
Epoch: 26 | train loss: 59.09166 |acc: 0.84 |recall: 0.823 |f1 score: 0.831 | validate loss: 68.508 |acc: 0.746 |recall: 0.758 |f1 score: 0.752
Epoch: 27 | train loss: 58.0584 |acc: 0.856 |recall: 0.84 |f1 score: 0.848 | validate loss: 53.158 |acc: 0.802 |recall: 0.774 |f1 score: 0.788
Epoch: 28 | train loss: 54.2857 |acc: 0.858 |recall: 0.834 |f1 score: 0.845 | validate loss: 60.243 |acc: 0.816 |recall: 0.772 |f1 score: 0.793
Epoch: 29 | train loss: 56.44759 |acc: 0.845 |recall: 0.838 |f1 score: 0.841 | validate loss: 56.497 |acc: 0.768 |recall: 0.77 |f1 score: 0.769
Epoch: 30 | train loss: 57.90492 |acc: 0.868 |recall: 0.832 |f1 score: 0.85 | validate loss: 75.158 |acc: 0.773 |recall: 0.762 |f1 score: 0.768
Epoch: 31 | train loss: 56.81468 |acc: 0.861 |recall: 0.835 |f1 score: 0.847 | validate loss: 56.742 |acc: 0.796 |recall: 0.784 |f1 score: 0.79
Epoch: 32 | train loss: 54.72623 |acc: 0.86 |recall: 0.844 |f1 score: 0.852 | validate loss: 63.175 |acc: 0.757 |recall: 0.78 |f1 score: 0.768
Epoch: 33 | train loss: 60.10299 |acc: 0.846 |recall: 0.813 |f1 score: 0.829 | validate loss: 68.994 |acc: 0.768 |recall: 0.724 |f1 score: 0.745
Epoch: 34 | train loss: 59.67491 |acc: 0.849 |recall: 0.826 |f1 score: 0.837 | validate loss: 58.662 |acc: 0.8 |recall: 0.739 |f1 score: 0.769
Epoch: 35 | train loss: 65.01099 |acc: 0.857 |recall: 0.83 |f1 score: 0.844 | validate loss: 69.299 |acc: 0.772 |recall: 0.752 |f1 score: 0.762
Epoch: 36 | train loss: 61.52783 |acc: 0.856 |recall: 0.828 |f1 score: 0.842 | validate loss: 82.373 |acc: 0.761 |recall: 0.777 |f1 score: 0.769
Epoch: 37 | train loss: 66.19576 |acc: 0.844 |recall: 0.822 |f1 score: 0.833 | validate loss: 79.853 |acc: 0.791 |recall: 0.77 |f1 score: 0.781
Epoch: 38 | train loss: 60.32529 |acc: 0.841 |recall: 0.828 |f1 score: 0.835 | validate loss: 69.346 |acc: 0.773 |recall: 0.755 |f1 score: 0.764
Epoch: 39 | train loss: 63.8836 |acc: 0.837 |recall: 0.819 |f1 score: 0.828 | validate loss: 74.759 |acc: 0.732 |recall: 0.759 |f1 score: 0.745
Epoch: 40 | train loss: 67.28363 |acc: 0.838 |recall: 0.824 |f1 score: 0.831 | validate loss: 63.027 |acc: 0.768 |recall: 0.764 |f1 score: 0.766
Epoch: 41 | train loss: 61.40488 |acc: 0.852 |recall: 0.826 |f1 score: 0.839 | validate loss: 58.976 |acc: 0.802 |recall: 0.755 |f1 score: 0.778
Epoch: 42 | train loss: 61.04982 |acc: 0.856 |recall: 0.817 |f1 score: 0.836 | validate loss: 58.47 |acc: 0.783 |recall: 0.74 |f1 score: 0.761
Epoch: 43 | train loss: 64.40567 |acc: 0.849 |recall: 0.821 |f1 score: 0.835 | validate loss: 63.506 |acc: 0.764 |recall: 0.765 |f1 score: 0.765
Epoch: 44 | train loss: 65.09746 |acc: 0.845 |recall: 0.805 |f1 score: 0.825 | validate loss: 65.535 |acc: 0.773 |recall: 0.743 |f1 score: 0.758
Epoch: 45 | train loss: 63.26585 |acc: 0.848 |recall: 0.808 |f1 score: 0.827 | validate loss: 62.477 |acc: 0.789 |recall: 0.733 |f1 score: 0.76
Epoch: 46 | train loss: 63.91504 |acc: 0.847 |recall: 0.812 |f1 score: 0.829 | validate loss: 59.916 |acc: 0.779 |recall: 0.751 |f1 score: 0.765
Epoch: 47 | train loss: 62.3592 |acc: 0.845 |recall: 0.824 |f1 score: 0.835 | validate loss: 63.363 |acc: 0.775 |recall: 0.761 |f1 score: 0.768
Epoch: 48 | train loss: 63.13221 |acc: 0.843 |recall: 0.823 |f1 score: 0.833 | validate loss: 65.71 |acc: 0.767 |recall: 0.755 |f1 score: 0.761
Epoch: 49 | train loss: 64.9964 |acc: 0.845 |recall: 0.811 |f1 score: 0.828 | validate loss: 65.174 |acc: 0.768 |recall: 0.74 |f1 score: 0.754
Epoch: 50 | train loss: 62.40605 |acc: 0.847 |recall: 0.817 |f1 score: 0.832 | validate loss: 60.761 |acc: 0.776 |recall: 0.746 |f1 score: 0.761
Epoch: 51 | train loss: 63.05476 |acc: 0.845 |recall: 0.812 |f1 score: 0.828 | validate loss: 64.217 |acc: 0.764 |recall: 0.748 |f1 score: 0.756
Epoch: 52 | train loss: 59.77727 |acc: 0.84 |recall: 0.831 |f1 score: 0.836 | validate loss: 60.48 |acc: 0.79 |recall: 0.759 |f1 score: 0.774
Epoch: 53 | train loss: 62.7249 |acc: 0.828 |recall: 0.813 |f1 score: 0.821 | validate loss: 64.584 |acc: 0.757 |recall: 0.757 |f1 score: 0.757
Epoch: 54 | train loss: 61.1763 |acc: 0.842 |recall: 0.832 |f1 score: 0.837 | validate loss: 61.088 |acc: 0.775 |recall: 0.768 |f1 score: 0.771
Epoch: 55 | train loss: 64.04366 |acc: 0.835 |recall: 0.816 |f1 score: 0.826 | validate loss: 68.183 |acc: 0.784 |recall: 0.742 |f1 score: 0.762
Epoch: 56 | train loss: 66.76939 |acc: 0.84 |recall: 0.813 |f1 score: 0.827 | validate loss: 67.284 |acc: 0.77 |recall: 0.748 |f1 score: 0.759
Epoch: 57 | train loss: 67.85329 |acc: 0.826 |recall: 0.789 |f1 score: 0.807 | validate loss: 69.961 |acc: 0.766 |recall: 0.732 |f1 score: 0.749
Epoch: 58 | train loss: 64.79573 |acc: 0.84 |recall: 0.812 |f1 score: 0.826 | validate loss: 73.358 |acc: 0.754 |recall: 0.735 |f1 score: 0.745
Epoch: 59 | train loss: 65.36249 |acc: 0.862 |recall: 0.826 |f1 score: 0.844 | validate loss: 66.552 |acc: 0.783 |recall: 0.766 |f1 score: 0.774
Epoch: 60 | train loss: 63.43061 |acc: 0.835 |recall: 0.811 |f1 score: 0.823 | validate loss: 63.138 |acc: 0.771 |recall: 0.746 |f1 score: 0.759
Epoch: 61 | train loss: 62.34639 |acc: 0.848 |recall: 0.825 |f1 score: 0.836 | validate loss: 59.656 |acc: 0.783 |recall: 0.756 |f1 score: 0.769
Epoch: 62 | train loss: 61.83451 |acc: 0.83 |recall: 0.814 |f1 score: 0.822 | validate loss: 60.443 |acc: 0.765 |recall: 0.751 |f1 score: 0.758
Epoch: 63 | train loss: 64.78461 |acc: 0.854 |recall: 0.818 |f1 score: 0.836 | validate loss: 61.125 |acc: 0.786 |recall: 0.748 |f1 score: 0.767
Epoch: 64 | train loss: 63.43409 |acc: 0.838 |recall: 0.818 |f1 score: 0.828 | validate loss: 62.396 |acc: 0.77 |recall: 0.757 |f1 score: 0.764
Epoch: 65 | train loss: 61.20197 |acc: 0.854 |recall: 0.815 |f1 score: 0.834 | validate loss: 59.019 |acc: 0.79 |recall: 0.75 |f1 score: 0.769
Epoch: 66 | train loss: 59.69791 |acc: 0.851 |recall: 0.82 |f1 score: 0.836 | validate loss: 55.06 |acc: 0.789 |recall: 0.754 |f1 score: 0.771
Epoch: 67 | train loss: 63.16074 |acc: 0.836 |recall: 0.811 |f1 score: 0.823 | validate loss: 61.48 |acc: 0.764 |recall: 0.745 |f1 score: 0.755
Epoch: 68 | train loss: 62.15521 |acc: 0.845 |recall: 0.824 |f1 score: 0.835 | validate loss: 62.407 |acc: 0.778 |recall: 0.761 |f1 score: 0.769
Epoch: 69 | train loss: 61.90574 |acc: 0.847 |recall: 0.828 |f1 score: 0.838 | validate loss: 59.801 |acc: 0.781 |recall: 0.762 |f1 score: 0.771
Epoch: 70 | train loss: 60.51348 |acc: 0.852 |recall: 0.827 |f1 score: 0.839 | validate loss: 56.632 |acc: 0.781 |recall: 0.761 |f1 score: 0.771
Epoch: 71 | train loss: 62.78683 |acc: 0.856 |recall: 0.823 |f1 score: 0.84 | validate loss: 62.867 |acc: 0.796 |recall: 0.757 |f1 score: 0.776
Epoch: 72 | train loss: 62.11708 |acc: 0.845 |recall: 0.82 |f1 score: 0.833 | validate loss: 57.211 |acc: 0.784 |recall: 0.754 |f1 score: 0.769
Epoch: 73 | train loss: 63.2298 |acc: 0.839 |recall: 0.816 |f1 score: 0.828 | validate loss: 60.247 |acc: 0.764 |recall: 0.752 |f1 score: 0.758
Epoch: 74 | train loss: 61.87119 |acc: 0.848 |recall: 0.828 |f1 score: 0.838 | validate loss: 59.692 |acc: 0.782 |recall: 0.765 |f1 score: 0.774
Epoch: 75 | train loss: 59.88628 |acc: 0.851 |recall: 0.821 |f1 score: 0.836 | validate loss: 59.461 |acc: 0.78 |recall: 0.755 |f1 score: 0.767
Epoch: 76 | train loss: 61.97182 |acc: 0.858 |recall: 0.812 |f1 score: 0.835 | validate loss: 59.748 |acc: 0.78 |recall: 0.749 |f1 score: 0.765
Epoch: 77 | train loss: 62.2035 |acc: 0.836 |recall: 0.811 |f1 score: 0.823 | validate loss: 56.778 |acc: 0.768 |recall: 0.748 |f1 score: 0.758
Epoch: 78 | train loss: 59.90309 |acc: 0.846 |recall: 0.823 |f1 score: 0.835 | validate loss: 59.424 |acc: 0.771 |recall: 0.76 |f1 score: 0.765
Epoch: 79 | train loss: 62.48097 |acc: 0.844 |recall: 0.821 |f1 score: 0.833 | validate loss: 57.535 |acc: 0.769 |recall: 0.755 |f1 score: 0.762
Epoch: 80 | train loss: 65.83723 |acc: 0.853 |recall: 0.83 |f1 score: 0.842 | validate loss: 60.798 |acc: 0.782 |recall: 0.762 |f1 score: 0.772
Epoch: 81 | train loss: 67.69897 |acc: 0.848 |recall: 0.812 |f1 score: 0.83 | validate loss: 62.135 |acc: 0.78 |recall: 0.746 |f1 score: 0.763
Epoch: 82 | train loss: 64.45554 |acc: 0.863 |recall: 0.845 |f1 score: 0.854 | validate loss: 62.102 |acc: 0.793 |recall: 0.775 |f1 score: 0.784
Epoch: 83 | train loss: 59.9239 |acc: 0.857 |recall: 0.84 |f1 score: 0.848 | validate loss: 57.003 |acc: 0.788 |recall: 0.771 |f1 score: 0.779
Epoch: 84 | train loss: 65.42567 |acc: 0.859 |recall: 0.831 |f1 score: 0.845 | validate loss: 61.993 |acc: 0.788 |recall: 0.763 |f1 score: 0.775
Epoch: 85 | train loss: 62.69893 |acc: 0.852 |recall: 0.828 |f1 score: 0.84 | validate loss: 59.489 |acc: 0.786 |recall: 0.761 |f1 score: 0.773
Epoch: 86 | train loss: 64.58199 |acc: 0.858 |recall: 0.831 |f1 score: 0.845 | validate loss: 60.414 |acc: 0.789 |recall: 0.764 |f1 score: 0.776
Epoch: 87 | train loss: 58.41865 |acc: 0.875 |recall: 0.838 |f1 score: 0.856 | validate loss: 56.525 |acc: 0.805 |recall: 0.768 |f1 score: 0.786
Epoch: 88 | train loss: 61.39529 |acc: 0.848 |recall: 0.824 |f1 score: 0.836 | validate loss: 56.678 |acc: 0.783 |recall: 0.759 |f1 score: 0.771
Epoch: 89 | train loss: 63.69639 |acc: 0.857 |recall: 0.818 |f1 score: 0.837 | validate loss: 59.014 |acc: 0.787 |recall: 0.751 |f1 score: 0.769
Epoch: 90 | train loss: 61.78225 |acc: 0.841 |recall: 0.84 |f1 score: 0.84 | validate loss: 59.58 |acc: 0.773 |recall: 0.775 |f1 score: 0.774
Epoch: 91 | train loss: 58.19114 |acc: 0.845 |recall: 0.826 |f1 score: 0.836 | validate loss: 55.284 |acc: 0.776 |recall: 0.758 |f1 score: 0.767
Epoch: 92 | train loss: 58.67227 |acc: 0.857 |recall: 0.82 |f1 score: 0.838 | validate loss: 54.982 |acc: 0.787 |recall: 0.753 |f1 score: 0.77
Epoch: 93 | train loss: 60.79532 |acc: 0.858 |recall: 0.83 |f1 score: 0.844 | validate loss: 57.808 |acc: 0.792 |recall: 0.764 |f1 score: 0.778
Epoch: 94 | train loss: 56.71145 |acc: 0.872 |recall: 0.851 |f1 score: 0.861 | validate loss: 53.551 |acc: 0.804 |recall: 0.785 |f1 score: 0.795
Epoch: 95 | train loss: 58.791 |acc: 0.864 |recall: 0.83 |f1 score: 0.847 | validate loss: 54.284 |acc: 0.793 |recall: 0.765 |f1 score: 0.779
Epoch: 96 | train loss: 60.07491 |acc: 0.849 |recall: 0.828 |f1 score: 0.839 | validate loss: 55.524 |acc: 0.78 |recall: 0.764 |f1 score: 0.772
Epoch: 97 | train loss: 61.53479 |acc: 0.86 |recall: 0.825 |f1 score: 0.842 | validate loss: 56.891 |acc: 0.796 |recall: 0.759 |f1 score: 0.777
Epoch: 98 | train loss: 61.94878 |acc: 0.85 |recall: 0.836 |f1 score: 0.843 | validate loss: 57.019 |acc: 0.783 |recall: 0.771 |f1 score: 0.777
Epoch: 99 | train loss: 58.49541 |acc: 0.86 |recall: 0.834 |f1 score: 0.847 | validate loss: 56.162 |acc: 0.795 |recall: 0.767 |f1 score: 0.781

5.6 第六步: 损失曲线和评估曲线图分析

5.6.1 训练和验证损失对照曲线:

在这里插入图片描述
分析: 损失对照曲线一直下降, 从第5个epoch开始, 迅速降到比较理想的位置, 说明模型能够从数据中获取规律了, 到第40个批次之后, 模型趋于稳定, 说明参数基本能够已经得到最优化效果, 此时, 根据对scheduler的设置, 通过该方法已经对优化器进行了近8次的迭代, 应该在我们原本设置的初始学习率基础上缩小了0.2的8次方倍, 此时应该找到了当前最优解, 因此也就趋于稳定了

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

在这里插入图片描述

分析:

  • 首先,准确率是指识别正确的实体识别出的实体中的比例.
  • 根据对照曲线来看,整体学习结果都在趋于准确率上升方向增加,而且随着批次的增加曲线震动相对平稳,不过可能由于训练与验证样本分布不均衡或者噪声等原因,导致最终验证集的准确度没有达到与训练集相同的情况.
  • 最终的训练集和验证集的召回率分别在:0.85和0.78左右.

5.6.3 训练和验证召回率对照曲线

在这里插入图片描述

分析:

  • 在此召回率是指识别正确的实体占当前批次所包含的所有实体总数的比例.
  • 关于训练和验证召回率对照曲线,可以看出召回率的变化相对比较平滑,基本上也在40步左右趋于稳定.
  • 最终的训练集和验证集的召回率分别在:0.83和0.75左右.

5.6.4 训练和验证F1值对照曲线

在这里插入图片描述
分析:

  • F1值主要是指训练效果而言,在不多识别实体的情况下同时提高准确度的衡量指标.
  • 其公式为:2×准确率×召回率 / (准确率**+**召回率)
  • 从曲线可见整体F1值上升与损失、召回率的曲线比较接近,说明在识别出的实体中,正确率比较问题,不过根据前面的准确度来分析,可能在识别过程中,增加了识别出的实体个数而导致不稳定。从这方面来说,可以验证样本不均衡问题以及噪声对模型的影响还是比较大的。
  • 从整体而言,F1值基本也在第40步之后趋于稳定,最终的训练集和验证集的结果在:0.85和0.75左右。

6、模型使用

6.1 模型单条文本预测代码实现

import os
import torch
import json
from bilstm_crf import BiLSTM_CRF


# 工具函数:将文本序列化
def content2id(content, char2id):
    content_ids = []   # 定义字符串对应的码表 id 列表
    for char in list(content):
        if char2id.get(char):   # 判断若字符不在码表对应字典中,则取 NUK 的编码(即 unknown),否则取对应的字符编码
            content_ids.append(char2id[char])
        else:
            content_ids.append(char2id["UNK"])
    return content_ids

def singel_predict(model_path, content, char2id_json_path, batch_size, embedding_size, hidden_size, num_layers, sentence_length, offset, target_type_list, tag2id):
    char2id = json.load(open(char2id_json_path, mode="r", encoding="utf-8"))
    content_ids = content2id(content, char2id) # 将字符串转为码表id列表
    print("content_ids = {0}".format(content_ids))
    input_ids_list, input_chars_list = build_input_ids_list(content, content_ids,  batch_size,  sentence_length,  offset) # 定义模型输入列表【处理成 batch_size * sentence_length 的 tensor 数据】
    print("input_ids_list = \n{0}".format(input_ids_list))
    print("input_chars_list = \n{0}".format(input_chars_list))
    # 实例化模型【传入的参数要和训练的时候一致才能加载训练时保存的模型(其中batch_size可以不一致)】
    model = BiLSTM_CRF(vocab_size=len(char2id), tag2id=tag2id,  embedding_size=embedding_size, hidden_size=hidden_size, batch_size=batch_size, num_layers=num_layers, sentence_length=sentence_length)  # 加载模型
    model.load_state_dict(torch.load(model_path))   # 加载模型
    tag_id_dict = {
    
    v: k for k, v in tag2id.items() if k[2:] in target_type_list}    # {3: 'B-sym', 4: 'I-sym'} 只预测target_type_list中规定类型的命名实体【即:包含sym字符的命名实体】
    print("target_type_list = {0}".format(target_type_list))
    print("tag_id_dict = {0}".format(tag_id_dict))
    entities = []   # 定义返回实体列表
    with torch.no_grad():
        for batch_index, model_id_inputs in enumerate(input_ids_list):
            print("="*30, "batch_index = {}".format(batch_index), "="*30)
            print("model_id_inputs = \n{}".format(model_id_inputs))
            print("model_char_inputs = \n{}".format(input_chars_list[batch_index]))
            prediction_value = model(model_id_inputs)  # 预测
            print("prediction_value = \n{}".format(prediction_value))
            for line_idx, line_value in enumerate(prediction_value): # 获取每一行预测结果
                entity = None   # 定义将要识别的实体
                for char_idx, tag_id in enumerate(line_value):  # 获取当前行每个字的预测结果
                    if tag_id in tag_id_dict:   # 若预测结果 tag_id 属于目标字典数据 key 中 {3: 'B-sym', 4: 'I-sym'},即:tag_id只取3或4
                        tag_index = tag_id_dict[tag_id][0]  # 取符合匹配字典id的第一个字符,即 tag_index=B 或 tag_index=I
                        current_char = input_chars_list[batch_index][line_idx][char_idx]    # 计算当前字符确切的下标位置
                        if tag_index == "B":    # 若当前字标签起始为 B, 则设置为实体开始
                            entity = current_char
                        elif tag_index == "I" and entity:   # 若当前字标签起始为 I, 则进行字符串追加
                            entity += current_char
                    if entity and tag_id == tag2id["O"]:    # 当实体不为空且当前标签类型为 O 时,表明上一个时间步的命名实体结束,将该实体加入实体列表
                        print("entity = {}".format(entity))
                        entities.append(entity) # 满足当前字符为O,上一个字符为目标提取实体结尾时,将其加入实体列表
                        entity = None   # 重置实体
    return entities


def build_input_ids_list(content, content_ids, batch_size, sentence_length, offset):
    input_ids_list = []   # 定义模型输入数据列表
    batch_sentence_list = []    # 定义每个批次句子 id 数据
    content_list = list(content)    # 将文本内容转为列表
    input_chars_list = []   # 定义与模型 char_id 对照的文字
    batch_sentence_char_list = []   #  定义每个批次句子字符数据
    if len(content_ids) % sentence_length > 0: # 判断是否需要 padding【将不足 batch_size * sentence_length 的部分填充0】
        padding_length = (batch_size * sentence_length - len(content_ids) % batch_size * sentence_length - len(content_ids) % sentence_length)
        content_ids.extend([0] * padding_length)   # 填充0
        content_list.extend(["#"] * padding_length) # 填充“#”
    for batch_index, idx in enumerate(range(0, len(content_ids) + 1, sentence_length)):   # 迭代字符 id 列表【数据满足 batch_size * sentence_length 将加入 input_ids_list】
        start_idx = 0 if idx == 0 else idx - batch_index * offset  # 起始下标,从第一句开始增加 offset 个字的偏移
        sub_list = content_ids[start_idx:start_idx + sentence_length]  # 获取长度为 sentence_length 的字符 id 数据集
        sub_char_list = content_list[start_idx:start_idx + sentence_length] # 获取长度为 sentence_length 的字符数据集
        batch_sentence_list.append(sub_list)    # 加入批次数据集中
        batch_sentence_char_list.append(sub_char_list)  # 批量句子包含字符列表
        if len(batch_sentence_list) == batch_size:  # 每当批次长度达到 batch_size 时候,将其加入 input_ids_list
            input_ids_list.append(torch.tensor(batch_sentence_list))  # 将数据格式转为 tensor 格式,大小为 batch_size * sentence_length
            batch_sentence_list = []    # 重置 batch_sentence_list
            input_chars_list.append(batch_sentence_char_list)   # 将 char_id 对应的字符加入映射表中
            batch_sentence_char_list = []   # 重置批字符串内容
    return input_ids_list, input_chars_list   # 返回模型输入列表

# 测试单条文本预测
if __name__=="__main__":
    # 参数1:待识别文本
    content = "本病是由DNA病毒的单纯疱疹病毒所致。人类单纯疱疹病毒分为两型,即单纯疱疹病毒Ⅰ型(HSV-Ⅰ)和单纯疱疹病毒Ⅱ型(HSV-Ⅱ)。Ⅰ型主要引起生殖器以外的皮肤黏膜(口腔黏膜)和器官(脑)的感染。Ⅱ型主要引起生殖器部位皮肤黏膜感染。病毒经呼吸道、口腔、生殖器黏膜以及破损皮肤进入体内,潜居于人体正常黏膜、血液、唾液及感觉神经节细胞内。当机体抵抗力下降时,如发热胃肠功能紊乱、月经、疲劳等时,体内潜伏的HSV被激活而发病。"
    # 参数2:模型保存文件路径
    model_path = "./saved_model/bilstm_crf_state_dict_20210325_152420.pt"
    # 参数3:字符码表文件路径
    char2id_json_path = "./data/char_to_id.json"
    # 参数4:批次大小
    BATCH_SIZE = 2
    # 参数5:字向量维度
    EMBEDDING_DIM = 200
    # 参数6:隐层维度
    HIDDEN_SIZE = 100
    # 参数7:句子长度
    SENTENCE_LENGTH = 20
    # 参数8:堆叠 LSTM 层数
    NUM_LAYERS = 1
    # 参数9:偏移量
    OFFSET = 10
    # 参数10:待匹配标签类型
    target_type_list = ["sym"]  # 表示只预测sym类型的命名实体
    # 参数11:标签码表对照字典
    tag2id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4, "<START>": 5, "<STOP>": 6}
    # 参数11:预测结果存储路径
    prediction_result_path = "prediction_result"

    entities = singel_predict(model_path, content, char2id_json_path, BATCH_SIZE, EMBEDDING_DIM, HIDDEN_SIZE, NUM_LAYERS, SENTENCE_LENGTH, OFFSET, target_type_list, tag2id)    # 单独文本预测, 获得实体结果
    print("entities:\n", entities)  # 打印实体结果

代码实现位置: /data/doctor_offline/ner_model/predict.py

输出效果:

content_ids = [631, 1102, 14575, 18227, 22246, 22255, 22243, 1102, 3034, 3303, 1588, 2557, 12375, 1884, 1102, 3034, 15243, 20186, 21252, 13734, 9706, 1588, 2557, 12375, 1884, 1102, 3034, 4521, 16282, 10117, 17426, 21253, 7078, 1588, 2557, 12375, 1884, 1102, 3034, 21045, 17426, 21316, 22250, 22260, 22263, 21312, 21045, 21317, 6151, 1588, 2557, 12375, 1884, 1102, 3034, 21046, 17426, 21316, 22250, 22260, 22263, 21312, 21046, 21317, 21252, 21045, 17426, 20378, 18255, 18687, 12915, 14493, 14583, 12777, 18372, 16103, 3303, 12525, 4805, 12014, 11643, 21316, 9090, 9083, 12014, 11643, 21317, 6151, 12777, 5604, 21316, 11870, 21317, 3303, 4995, 13675, 21252, 21046, 17426, 20378, 18255, 18687, 12915, 14493, 14583, 12777, 1287, 16285, 12525, 4805, 12014, 11643, 4995, 13675, 21252, 1102, 3034, 8284, 6411, 16673, 3282, 21254, 9090, 9083, 21254, 14493, 14583, 12777, 12014, 11643, 18372, 7080, 12696, 15213, 12525, 4805, 8193, 13893, 622, 11742, 21253, 13015, 7111, 17540, 13734, 622, 20035, 1715, 12014, 11643, 21254, 17358, 14586, 21254, 16000, 14586, 7080, 4995, 7922, 14434, 8284, 8059, 16798, 500, 11742, 21252, 3191, 7155, 622, 3370, 8937, 9774, 16891, 7808, 14573, 21253, 13896, 1132, 13729, 16383, 1741, 5310, 11796, 16409, 10801, 21254, 19403, 8284, 21254, 12521, 9583, 3325, 14573, 21253, 622, 11742, 13015, 4693, 3303, 22250, 22260, 22263, 600, 7188, 5887, 4257, 1132, 1102, 21252]
input_ids_list = 
[
	tensor([
		[  631,  1102, 14575, 18227, 22246, 22255, 22243,  1102,  3034,  3303, 1588,  2557, 12375,  1884,  1102,  3034, 15243, 20186, 21252, 13734],
        [ 1588,  2557, 12375,  1884,  1102,  3034, 15243, 20186, 21252, 13734, 9706,  1588,  2557, 12375,  1884,  1102,  3034,  4521, 16282, 10117]]), 
	tensor([
		[ 9706,  1588,  2557, 12375,  1884,  1102,  3034,  4521, 16282, 10117, 17426, 21253,  7078,  1588,  2557, 12375,  1884,  1102,  3034, 21045],
        [17426, 21253,  7078,  1588,  2557, 12375,  1884,  1102,  3034, 21045,  17426, 21316, 22250, 22260, 22263, 21312, 21045, 21317,  6151,  1588]]), 
	tensor([
		[17426, 21316, 22250, 22260, 22263, 21312, 21045, 21317,  6151,  1588, 2557, 12375,  1884,  1102,  3034, 21046, 17426, 21316, 22250, 22260],
        [ 2557, 12375,  1884,  1102,  3034, 21046, 17426, 21316, 22250, 22260, 22263, 21312, 21046, 21317, 21252, 21045, 17426, 20378, 18255, 18687]]), 
	tensor([
		[22263, 21312, 21046, 21317, 21252, 21045, 17426, 20378, 18255, 18687, 12915, 14493, 14583, 12777, 18372, 16103,  3303, 12525,  4805, 12014],
        [12915, 14493, 14583, 12777, 18372, 16103,  3303, 12525,  4805, 12014, 11643, 21316,  9090,  9083, 12014, 11643, 21317,  6151, 12777,  5604]]), 
	tensor([
		[11643, 21316,  9090,  9083, 12014, 11643, 21317,  6151, 12777,  5604, 21316, 11870, 21317,  3303,  4995, 13675, 21252, 21046, 17426, 20378],
        [21316, 11870, 21317,  3303,  4995, 13675, 21252, 21046, 17426, 20378, 18255, 18687, 12915, 14493, 14583, 12777,  1287, 16285, 12525,  4805]]), 
	tensor([
		[18255, 18687, 12915, 14493, 14583, 12777,  1287, 16285, 12525,  4805, 12014, 11643,  4995, 13675, 21252,  1102,  3034,  8284,  6411, 16673],
        [12014, 11643,  4995, 13675, 21252,  1102,  3034,  8284,  6411, 16673, 3282, 21254,  9090,  9083, 21254, 14493, 14583, 12777, 12014, 11643]])]
input_chars_list = 
[
	[
		['本', '病', '是', '由', 'D', 'N', 'A', '病', '毒', '的', '单', '纯', '疱', '疹', '病', '毒', '所', '致', '。', '人'], 
		['单', '纯', '疱', '疹', '病', '毒', '所', '致', '。', '人', '类', '单', '纯', '疱', '疹', '病', '毒', '分', '为', '两']], 	
	[
		['类', '单', '纯', '疱', '疹', '病', '毒', '分', '为', '两', '型', ',', '即', '单', '纯', '疱', '疹', '病', '毒', 'Ⅰ'], 
		['型', ',', '即', '单', '纯', '疱', '疹', '病', '毒', 'Ⅰ', '型', '(', 'H', 'S', 'V', '-', 'Ⅰ', ')', '和', '单']], 
	[
		['型', '(', 'H', 'S', 'V', '-', 'Ⅰ', ')', '和', '单', '纯', '疱', '疹', '病', '毒', 'Ⅱ', '型', '(', 'H', 'S'], 
		['纯', '疱', '疹', '病', '毒', 'Ⅱ', '型', '(', 'H', 'S', 'V', '-', 'Ⅱ', ')', '。', 'Ⅰ', '型', '主', '要', '引']], 
	[
		['V', '-', 'Ⅱ', ')', '。', 'Ⅰ', '型', '主', '要', '引', '起', '生', '殖', '器', '以', '外', '的', '皮', '肤', '黏'], 
		['起', '生', '殖', '器', '以', '外', '的', '皮', '肤', '黏', '膜', '(', '口', '腔', '黏', '膜', ')', '和', '器', '官']], 
	[
		['膜', '(', '口', '腔', '黏', '膜', ')', '和', '器', '官', '(', '脑', ')', '的', '感', '染', '。', 'Ⅱ', '型', '主'], 
		['(', '脑', ')', '的', '感', '染', '。', 'Ⅱ', '型', '主', '要', '引', '起', '生', '殖', '器', '部', '位', '皮', '肤']], 
	[['要', '引', '起', '生', '殖', '器', '部', '位', '皮', '肤', '黏', '膜', '感', '染', '。', '病', '毒', '经', '呼', '吸'], ['黏', '膜', '感', '染', '。', '病', '毒', '经', '呼', '吸', '道', '、', '口', '腔', '、', '生', '殖', '器', '黏', '膜']]]

target_type_list = ['sym']

tag_id_dict = {
    
    3: 'B-sym', 4: 'I-sym'}

============================== batch_index = 0 ==============================
model_id_inputs = 
tensor(
[
	[  631,  1102, 14575, 18227, 22246, 22255, 22243,  1102,  3034,  3303, 1588,  2557, 12375,  1884,  1102,  3034, 15243, 20186, 21252, 13734],
	[ 1588,  2557, 12375,  1884,  1102,  3034, 15243, 20186, 21252, 13734, 9706,  1588,  2557, 12375,  1884,  1102,  3034,  4521, 16282, 10117]
]
)
model_char_inputs = 
[
	['本', '病', '是', '由', 'D', 'N', 'A', '病', '毒', '的', '单', '纯', '疱', '疹', '病', '毒', '所', '致', '。', '人'], 
	['单', '纯', '疱', '疹', '病', '毒', '所', '致', '。', '人', '类', '单', '纯', '疱', '疹', '病', '毒', '分', '为', '两']
]
prediction_value = 
[
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]
============================== batch_index = 1 ==============================
model_id_inputs = 
tensor(
[
	[ 9706,  1588,  2557, 12375,  1884,  1102,  3034,  4521, 16282, 10117, 17426, 21253,  7078,  1588,  2557, 12375,  1884,  1102,  3034, 21045],
	[17426, 21253,  7078,  1588,  2557, 12375,  1884,  1102,  3034, 21045,  17426, 21316, 22250, 22260, 22263, 21312, 21045, 21317,  6151,  1588]
]
)
model_char_inputs = 
[
	['类', '单', '纯', '疱', '疹', '病', '毒', '分', '为', '两', '型', ',', '即', '单', '纯', '疱', '疹', '病', '毒', 'Ⅰ'], 
	['型', ',', '即', '单', '纯', '疱', '疹', '病', '毒', 'Ⅰ', '型', '(', 'H', 'S', 'V', '-', 'Ⅰ', ')', '和', '单']
]
prediction_value = 
[
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 5, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 6, 5, 0, 0, 0, 0]
]
============================== batch_index = 2 ==============================
model_id_inputs = 
tensor(
[
	[17426, 21316, 22250, 22260, 22263, 21312, 21045, 21317,  6151,  1588, 2557, 12375,  1884,  1102,  3034, 21046, 17426, 21316, 22250, 22260],
	[ 2557, 12375,  1884,  1102,  3034, 21046, 17426, 21316, 22250, 22260, 22263, 21312, 21046, 21317, 21252, 21045, 17426, 20378, 18255, 18687]
]
)
model_char_inputs = 
[
	['型', '(', 'H', 'S', 'V', '-', 'Ⅰ', ')', '和', '单', '纯', '疱', '疹', '病', '毒', 'Ⅱ', '型', '(', 'H', 'S'], 
	['纯', '疱', '疹', '病', '毒', 'Ⅱ', '型', '(', 'H', 'S', 'V', '-', 'Ⅱ', ')', '。', 'Ⅰ', '型', '主', '要', '引']
]
prediction_value = 
[
	[0, 5, 0, 6, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 5, 0, 0, 0, 6, 4, 0]
]
============================== batch_index = 3 ==============================
model_id_inputs = 
tensor(
[
	[22263, 21312, 21046, 21317, 21252, 21045, 17426, 20378, 18255, 18687, 12915, 14493, 14583, 12777, 18372, 16103,  3303, 12525,  4805, 12014],
	[12915, 14493, 14583, 12777, 18372, 16103,  3303, 12525,  4805, 12014, 11643, 21316,  9090,  9083, 12014, 11643, 21317,  6151, 12777,  5604]
]
)
model_char_inputs = 
[
	['V', '-', 'Ⅱ', ')', '。', 'Ⅰ', '型', '主', '要', '引', '起', '生', '殖', '器', '以', '外', '的', '皮', '肤', '黏'], 
	['起', '生', '殖', '器', '以', '外', '的', '皮', '肤', '黏', '膜', '(', '口', '腔', '黏', '膜', ')', '和', '器', '官']
]
prediction_value = 
[
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 5, 0], 
	[0, 6, 5, 0, 0, 0, 0, 0, 0, 6, 5, 0, 0, 0, 0, 6, 5, 0, 0, 0]
]
============================== batch_index = 4 ==============================
model_id_inputs = 
tensor(
[
	[11643, 21316,  9090,  9083, 12014, 11643, 21317,  6151, 12777,  5604, 21316, 11870, 21317,  3303,  4995, 13675, 21252, 21046, 17426, 20378],
	[21316, 11870, 21317,  3303,  4995, 13675, 21252, 21046, 17426, 20378, 18255, 18687, 12915, 14493, 14583, 12777,  1287, 16285, 12525,  4805]
]
)
model_char_inputs = 
[
	['膜', '(', '口', '腔', '黏', '膜', ')', '和', '器', '官', '(', '脑', ')', '的', '感', '染', '。', 'Ⅱ', '型', '主'], 
	['(', '脑', ')', '的', '感', '染', '。', 'Ⅱ', '型', '主', '要', '引', '起', '生', '殖', '器', '部', '位', '皮', '肤']
]
prediction_value = 
[
	[0, 0, 0, 6, 4, 5, 0, 0, 0, 0, 6, 5, 0, 0, 0, 0, 0, 0, 0, 0], 
	[0, 6, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 3, 4, 0]
]
entity = )的
entity = 位皮
============================== batch_index = 5 ==============================
model_id_inputs = 
tensor(
[
	[18255, 18687, 12915, 14493, 14583, 12777,  1287, 16285, 12525,  4805, 12014, 11643,  4995, 13675, 21252,  1102,  3034,  8284,  6411, 16673],
	[12014, 11643,  4995, 13675, 21252,  1102,  3034,  8284,  6411, 16673, 3282, 21254,  9090,  9083, 21254, 14493, 14583, 12777, 12014, 11643]
]
)
model_char_inputs = 
[
	['要', '引', '起', '生', '殖', '器', '部', '位', '皮', '肤', '黏', '膜', '感', '染', '。', '病', '毒', '经', '呼', '吸'], 
	['黏', '膜', '感', '染', '。', '病', '毒', '经', '呼', '吸', '道', '、', '口', '腔', '、', '生', '殖', '器', '黏', '膜']
]
prediction_value = 
[
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0], 
	[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 5, 0, 0]
]
entities:
 [')的', '位皮']

Process finished with exit code 0

以上结果因为模型训练的时间不够,所以不准确。

6.2 批量文件夹文件预测代码实现

def batch_predict(data_path, prediction_result_path, model_path, char2id_json_path, batch_size, embedding_size, hidden_size, num_layers, sentence_length, offset, target_type_list, tag2id):
    """
    description: 批量预测,查询文件目录下数据, 从中提取符合条件的实体并存储至新的目录下prediction_result_path
    :param data_path:               数据文件路径
    :param model_path:              模型文件路径
    :param char2id_json_path:    字符码表文件路径
    :param batch_size:              训练批次大小
    :param embedding_size:           字向量维度
    :param hidden_size:              BiLSTM 隐藏层向量维度
    :param sentence_length:         句子长度(句子做了padding)
    :param offset:                  设定偏移量, 当字符串超出sentence_length时, 换行时增加偏移量
    :param target_type_list:        待匹配类型,符合条件的实体将会被提取出来
    :param prediction_result_path:  预测结果保存路径
    :param tag2id:               标签码表对照字典, 标签对应 id
    :return:                        无返回
    """
    for fn in os.listdir(data_path):    # 迭代路径, 读取文件名
        fullpath = os.path.join(data_path, fn)  # 拼装全路径
        entities_file = open(os.path.join(prediction_result_path, fn), mode="w", encoding="utf-8")  # 定义输出结果文件
        with open(fullpath, mode="r", encoding="utf-8") as f:
            content = f.readline()  # 读取文件内容
            entities = singel_predict(model_path, content, char2id_json_path, batch_size, embedding_size, hidden_size, num_layers, sentence_length, offset, target_type_list, tag2id) # 调用单个预测模型,输出为目标类型实体文本列表
            entities_file.write("\n".join(entities))    # 写入识别结果文件
    print("batch_predict Finished".center(100, "-"))

代码实现位置: /data/doctor_offline/ner_model/predict.py

调用:

if __name__=="__main__":
    model_path = "./saved_model/bilstm_crf_state_dict_20210325_152420.pt"   # 参数2:模型保存文件路径
    char2id_json_path = "./data/char_to_id.json"    # 参数3:字符码表文件路径
    BATCH_SIZE = 2  # 参数4:批次大小
    EMBEDDING_SIZE = 200    # 参数5:字向量维度
    HIDDEN_SIZE = 100   # 参数6:隐层维度
    SENTENCE_LENGTH = 20    # 参数7:句子长度
    NUM_LAYERS = 1  # 参数8:堆叠 LSTM 层数
    OFFSET = 10 # 参数9:偏移量
    target_type_list = ["sym"]  # 表示只预测sym类型的命名实体   # 参数10:待匹配标签类型
    tag2id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4, "<START>": 5, "<STOP>": 6} # 参数11:标签码表对照字典

    # 批量预测
    data_path = "origin_data"   # 待预测文本文件所在目录
    prediction_result_path = "prediction_result"    # 预测结果存储路径
    batch_predict(data_path, prediction_result_path, model_path, char2id_json_path, BATCH_SIZE, EMBEDDING_SIZE, HIDDEN_SIZE, NUM_LAYERS, SENTENCE_LENGTH, OFFSET, target_type_list, tag2id)

输出效果: 将识别结果保存至prediction_result_path指定的目录下, 名称与源文件一致, 内容为每行存储识别实体名称

6.3 预测文件完整代码

import os
import torch
import json
from bilstm_crf import BiLSTM_CRF


# 工具函数:将文本序列化
def content2id(content, char2id):
    content_ids = []   # 定义字符串对应的码表 id 列表
    for char in list(content):
        if char2id.get(char):   # 判断若字符不在码表对应字典中,则取 NUK 的编码(即 unknown),否则取对应的字符编码
            content_ids.append(char2id[char])
        else:
            content_ids.append(char2id["UNK"])
    return content_ids

# 工具函数:数据批次化
def build_input_ids_list(content, content_ids, batch_size, sentence_length, offset):
    input_ids_list = []   # 定义模型输入数据列表
    batch_sentence_list = []    # 定义每个批次句子 id 数据
    content_list = list(content)    # 将文本内容转为列表
    input_chars_list = []   # 定义与模型 char_id 对照的文字
    batch_sentence_char_list = []   #  定义每个批次句子字符数据
    if len(content_ids) % sentence_length > 0: # 判断是否需要 padding【将不足 batch_size * sentence_length 的部分填充0】
        padding_length = (batch_size * sentence_length - len(content_ids) % batch_size * sentence_length - len(content_ids) % sentence_length)
        content_ids.extend([0] * padding_length)   # 填充0
        content_list.extend(["#"] * padding_length) # 填充“#”
    for batch_index, idx in enumerate(range(0, len(content_ids) + 1, sentence_length)):   # 迭代字符 id 列表【数据满足 batch_size * sentence_length 将加入 input_ids_list】
        start_idx = 0 if idx == 0 else idx - batch_index * offset  # 起始下标,从第一句开始增加 offset 个字的偏移
        sub_list = content_ids[start_idx:start_idx + sentence_length]  # 获取长度为 sentence_length 的字符 id 数据集
        sub_char_list = content_list[start_idx:start_idx + sentence_length] # 获取长度为 sentence_length 的字符数据集
        batch_sentence_list.append(sub_list)    # 加入批次数据集中
        batch_sentence_char_list.append(sub_char_list)  # 批量句子包含字符列表
        if len(batch_sentence_list) == batch_size:  # 每当批次长度达到 batch_size 时候,将其加入 input_ids_list
            input_ids_list.append(torch.tensor(batch_sentence_list))  # 将数据格式转为 tensor 格式,大小为 batch_size * sentence_length
            batch_sentence_list = []    # 重置 batch_sentence_list
            input_chars_list.append(batch_sentence_char_list)   # 将 char_id 对应的字符加入映射表中
            batch_sentence_char_list = []   # 重置批字符串内容
    return input_ids_list, input_chars_list   # 返回模型输入列表


def singel_predict(model_path, content, char2id_json_path, batch_size, embedding_size, hidden_size, num_layers, sentence_length, offset, target_type_list, tag2id):
    char2id = json.load(open(char2id_json_path, mode="r", encoding="utf-8"))
    content_ids = content2id(content, char2id) # 将字符串转为码表id列表
    print("content_ids = {0}".format(content_ids))
    input_ids_list, input_chars_list = build_input_ids_list(content, content_ids,  batch_size,  sentence_length,  offset) # 定义模型输入列表【处理成 batch_size * sentence_length 的 tensor 数据】
    print("input_ids_list = \n{0}".format(input_ids_list))
    print("input_chars_list = \n{0}".format(input_chars_list))
    # 实例化模型【传入的参数要和训练的时候一致才能加载训练时保存的模型(其中batch_size可以不一致)】
    model = BiLSTM_CRF(vocab_size=len(char2id), tag2id=tag2id,  embedding_size=embedding_size, hidden_size=hidden_size, batch_size=batch_size, num_layers=num_layers, sentence_length=sentence_length)  # 加载模型
    model.load_state_dict(torch.load(model_path))   # 加载模型
    tag_id_dict = {
    
    v: k for k, v in tag2id.items() if k[2:] in target_type_list}    # {3: 'B-sym', 4: 'I-sym'} 只预测target_type_list中规定类型的命名实体【即:包含sym字符的命名实体】
    print("target_type_list = {0}".format(target_type_list))
    print("tag_id_dict = {0}".format(tag_id_dict))
    entities = []   # 定义返回实体列表
    with torch.no_grad():
        for batch_index, model_id_inputs in enumerate(input_ids_list):
            print("="*30, "batch_index = {}".format(batch_index), "="*30)
            print("model_id_inputs = \n{}".format(model_id_inputs))
            print("model_char_inputs = \n{}".format(input_chars_list[batch_index]))
            prediction_value = model(model_id_inputs)  # 预测
            print("prediction_value = \n{}".format(prediction_value))
            for line_idx, line_value in enumerate(prediction_value): # 获取每一行预测结果
                entity = None   # 定义将要识别的实体
                for char_idx, tag_id in enumerate(line_value):  # 获取当前行每个字的预测结果
                    if tag_id in tag_id_dict:   # 若预测结果 tag_id 属于目标字典数据 key 中 {3: 'B-sym', 4: 'I-sym'},即:tag_id只取3或4
                        tag_index = tag_id_dict[tag_id][0]  # 取符合匹配字典id的第一个字符,即 tag_index=B 或 tag_index=I
                        current_char = input_chars_list[batch_index][line_idx][char_idx]    # 计算当前字符确切的下标位置
                        if tag_index == "B":    # 若当前字标签起始为 B, 则设置为实体开始
                            entity = current_char
                        elif tag_index == "I" and entity:   # 若当前字标签起始为 I, 则进行字符串追加
                            entity += current_char
                    if entity and tag_id == tag2id["O"]:    # 当实体不为空且当前标签类型为 O 时,表明上一个时间步的命名实体结束,将该实体加入实体列表
                        print("entity = {}".format(entity))
                        entities.append(entity) # 满足当前字符为O,上一个字符为目标提取实体结尾时,将其加入实体列表
                        entity = None   # 重置实体
    return entities


def batch_predict(data_path, prediction_result_path, model_path, char2id_json_path, batch_size, embedding_size, hidden_size, num_layers, sentence_length, offset, target_type_list, tag2id):
    """
    description: 批量预测,查询文件目录下数据, 从中提取符合条件的实体并存储至新的目录下prediction_result_path
    :param data_path:               数据文件路径
    :param model_path:              模型文件路径
    :param char2id_json_path:    字符码表文件路径
    :param batch_size:              训练批次大小
    :param embedding_size:           字向量维度
    :param hidden_size:              BiLSTM 隐藏层向量维度
    :param sentence_length:         句子长度(句子做了padding)
    :param offset:                  设定偏移量, 当字符串超出sentence_length时, 换行时增加偏移量
    :param target_type_list:        待匹配类型,符合条件的实体将会被提取出来
    :param prediction_result_path:  预测结果保存路径
    :param tag2id:               标签码表对照字典, 标签对应 id
    :return:                        无返回
    """
    for fn in os.listdir(data_path):    # 迭代路径, 读取文件名
        fullpath = os.path.join(data_path, fn)  # 拼装全路径
        entities_file = open(os.path.join(prediction_result_path, fn), mode="w", encoding="utf-8")  # 定义输出结果文件
        with open(fullpath, mode="r", encoding="utf-8") as f:
            content = f.readline()  # 读取文件内容
            entities = singel_predict(model_path, content, char2id_json_path, batch_size, embedding_size, hidden_size, num_layers, sentence_length, offset, target_type_list, tag2id) # 调用单个预测模型,输出为目标类型实体文本列表
            entities_file.write("\n".join(entities))    # 写入识别结果文件
    print("batch_predict Finished".center(100, "-"))



if __name__=="__main__":
    # 参数1:待识别文本
    content = "本病是由DNA病毒的单纯疱疹病毒所致。人类单纯疱疹病毒分为两型,即单纯疱疹病毒Ⅰ型(HSV-Ⅰ)和单纯疱疹病毒Ⅱ型(HSV-Ⅱ)。Ⅰ型主要引起生殖器以外的皮肤黏膜(口腔黏膜)和器官(脑)的感染。Ⅱ型主要引起生殖器部位皮肤黏膜感染。病毒经呼吸道、口腔、生殖器黏膜以及破损皮肤进入体内,潜居于人体正常黏膜、血液、唾液及感觉神经节细胞内。当机体抵抗力下降时,如发热胃肠功能紊乱、月经、疲劳等时,体内潜伏的HSV被激活而发病。"
    model_path = "./saved_model/bilstm_crf_state_dict_20210325_152420.pt"   # 参数2:模型保存文件路径
    char2id_json_path = "./data/char_to_id.json"    # 参数3:字符码表文件路径
    BATCH_SIZE = 2  # 参数4:批次大小
    EMBEDDING_SIZE = 200    # 参数5:字向量维度
    HIDDEN_SIZE = 100   # 参数6:隐层维度
    SENTENCE_LENGTH = 20    # 参数7:句子长度
    NUM_LAYERS = 1  # 参数8:堆叠 LSTM 层数
    OFFSET = 10 # 参数9:偏移量
    target_type_list = ["sym"]  # 表示只预测sym类型的命名实体   # 参数10:待匹配标签类型
    tag2id = {
    
    "O": 0, "B-dis": 1, "I-dis": 2, "B-sym": 3, "I-sym": 4, "<START>": 5, "<STOP>": 6} # 参数11:标签码表对照字典

    # 单条文本预测
    entities = singel_predict(model_path, content, char2id_json_path, BATCH_SIZE, EMBEDDING_SIZE, HIDDEN_SIZE, NUM_LAYERS, SENTENCE_LENGTH, OFFSET, target_type_list, tag2id)    # 单独文本预测, 获得实体结果
    print("entities:\n", entities)  # 打印实体结果

    # 批量预测
    data_path = "origin_data"   # 待预测文本文件所在目录
    prediction_result_path = "prediction_result"    # 预测结果存储路径
    batch_predict(data_path, prediction_result_path, model_path, char2id_json_path, BATCH_SIZE, EMBEDDING_SIZE, HIDDEN_SIZE, NUM_LAYERS, SENTENCE_LENGTH, OFFSET, target_type_list, tag2id)

七、在线部分

1、在线部分简要分析

在线部分架构图:

在这里插入图片描述

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

werobot服务作用

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

主要逻辑服务作用:

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

句子相关模型服务:

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

2、werobot服务构建

werobot服务的构建过程可分为四步:

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

2.1 第一步: 获取服务器公网IP

登陆阿里云官网(https://www.aliyun.com/product/ecs):

在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

设置密码, 实例名称, 主机名
在这里插入图片描述

配置分组设置(默认配置)
在这里插入图片描述

确认订单并支付
在这里插入图片描述

查看服务器公网IP
在这里插入图片描述

2.2 第二步: werobot启动服务脚本编写、启动

安装werobot

pip install werobot

进行启动脚本的编写

# 导入werobot和发送请求的requests
import werobot
import requests

# 主要逻辑服务请求地址【所使用的阿里云ESC公网ip】
url = "http://***.***.***.***: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()

代码位置: /data/wr.py

启动服务脚本

python /data/wr.py

如果显示80端口被占用,可能被Nginx占用。可以先卸载Nginx,或进行Nginx转发。

2.3 第三步: 使用公网IP配置微信公众号

注册微信订阅号(https://mp.weixin.qq.com),在基本配置中,根据上一步wr.py文件中的 robot = werobot.WeRoBot(token="doctoraitoken")进行URL和Token设定

  • URL为阿里云公网地址
  • Token为wr.py文件中设置的的Token

在这里插入图片描述

2.4 第四步: 使用微信进行测试

在这里插入图片描述

3、主要逻辑服务

主要逻辑图:

在这里插入图片描述

逻辑图分析:

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

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

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

构建主要逻辑服务的步骤:

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

3.1 第一步: 导入必备工具和配置

# 服务框架使用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)

代码位置: /data/doctor_online/main_serve/app.py

配置文件内容如下:

# 设置redis相关的配置信息
REDIS_CONFIG = {
    
    
	"host": "0.0.0.0",
	"port": 6379
}
# 设置neo4j图数据库的配置信息
NEO4J_CONFIG = {
    
    
	"uri": "bolt://0.0.0.0:7687",
	"auth": ("neo4j", "*********"),
	"encrypted": False
}

# 设置句子相关性判断模型服务的请求地址【用于判断两条语句是否主题相关,端口号为5001,Flask Web服务的端口默认为5000】
model_serve_url = "http://0.0.0.0:5001/v1/recognition/"

# 设置服务的超时时间
TIMEOUT = 2

# 设置规则对话的模板加载路径
reply_path = "./reply.json"

# 用户对话信息保存的过期时间
ex_time = 36000

配置文件代码位置: /data/doctor_online/main_serve/config.py

规则对话模版文件reply.json内容如下:

{
    
    
"1": "亲爱的用户, 在线医生一个医患问答机器人,请您说一些当前的症状吧!",
"2": "根据您当前的症状描述, 您可能患有以下疾病, %s, 再想想还有更多的症状吗?",
"3": "对不起, 您所说的内容超出了在线医生的知识范围. 请尝试换一些描述方式!",
"4": "您的这次描述并没有给我带来更多信息,请您继续描述您的症状."
}

规则对话模版代码位置: /data/doctor_online/main_serve/reply.json

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

# 查询neo4j图数据的函数【根据用户对话文本中可能存在的疾病症状, 来查询图数据库, 返回对应的疾病名称;return: 用户描述的症状所对应的的疾病名称列表】
def query_neo4j(text):  # text: 用户输入的文本语句
    with _driver.session() as session:  # 开启一个会话session来操作图数据库
        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语句, 匹配句子中存在的所有症状节点,保存这些临时的节点为a, 并通过关系 dis_to_sym 进行对应疾病名称的查找, 返回找到的疾病名称列表
        record = session.run(cypher)    # 通过会话session来运行cypher语句
        result = list(map(lambda x: x[0], record))  # 从record中读取真正的疾病名称信息, 并封装成List返回
    return result

代码位置: /data/doctor_online/main_serve/app.py

调用:

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

输出效果:

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

3.3 第三步: 编写主要逻辑处理类

# 主要逻辑服务类Handler类
class Handler(object):
    def __init__(self, uid, text, r, reply):   # uid: 用户唯一标识uid; text: 标识该用户本次输入的文本信息; r: 代表redis数据库的一个链接对象; reply: 规则对话模板加载到内存中的对象(字典对象)
        self.uid = uid
        self.text = text
        self.r = r
        self.reply = reply

    # 非首句处理函数, 该用户不是第一句问话【return: 根据逻辑图返回非首句情况下的输出语句】
    def non_first_sentence(self, previous): # previous: 该用户当前句(输入文本)的上一句文本
        ###################################################
        # 能够打印该信息, 说明已经进入非首句处理函数.
        print("non_first_sentence------>准备请求句子相关模型服务!")  # 在此处打印信息, 说明服务已经可以进入到首句处理函数中
        ###################################################
        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)# 如果回复为空, 说明服务暂时不提供信息, 转去百度机器人回复
            ###################################################
            # 能够打印该信息, 说明句子相关模型服务请求成功.
            print("non_first_sentence------>句子相关模型服务请求成功, 返回结果为:", result.text)  # 此处打印信息, 说明句子相关模型服务请求成功且不为空
            ###################################################
        except Exception as e:  # 如果出现异常,也把流程转去百度机器人回复
            print("non_first_sentence------>模型服务异常:", e)
            return unit_chat(self.text)
        ###################################################
        # 能够打印该信息, 说明开始准备请求neo4j查询服务.
        print("non_first_sentence------>请求模型服务后, 准备请求neo4j查询服务!")    # 此处打印信息, 说明程序已经准备进行neo4j数据库查询
        ###################################################
        # 继续查询图数据库, 并获得结果
        s = query_neo4j(self.text)  # 查询图数据库, 并得到疾病名称的列表结果
        ###################################################
        # 能够打印该信息, 说明neo4j查询成功.
        print("non_first_sentence------>neo4j查询服务请求成功, 返回结果是:", s)   # 此处打印信息, 说明已经成功获得了neo4j的查询结果
        ###################################################
        if not s:   # 判断如果结果为空, 继续用百度机器人回复
            return unit_chat(self.text)
        old_disease = self.r.hget(str(self.uid), "previous_d")  # 如果结果不是空, 从redis中获取上一次已经回复给用户的疾病名称
        if old_disease: # 如果曾经回复过用户若干疾病名称, 将新查询的疾病和已经回复的疾病做并集, 再次存储【新查询的疾病, 要和曾经回复过的疾病做差集, 这个差集再次回复给用户】
            new_disease = list(set(s) | set(eval(old_disease))) # new_disease是本次需要存储进redis数据库的疾病, 做【并集】得来
            res = list(set(s) - set(eval(old_disease))) # 返回给用户的疾病res, 是本次查询结果和曾经的回复结果之间的【差集】
        else:
            res = new_disease = list(set(s))    # 如果曾经没有给该用户的回复疾病, 则存储的数据和返回给用户的数据相同, 都是从neo4j数据库查询返回的结果
        self.r.hset(str(self.uid), "previous_d", str(new_disease))  # 将new_disease存储进redis数据库中, 同时覆盖掉之前的old_disease
        self.r.expire(str(self.uid), ex_time)   # 设置redis数据的过期时间
        print("non_first_sentence------>使用规则对话模板进行返回对话的生成!") # 此处打印信息, 说明neo4j查询后已经处理完了redis任务, 开始使用规则对话模板
        # 将列表转化为字符串, 添加进规则对话模板中返回给用户
        if not res:
            return self.reply["4"]
        else:
            res = ",".join(res)
            ###################################################
            # 能够打印该信息, 说明neo4j查询后有结果并将使用规则对话模版.
            print("使用规则对话模版进行返回对话的生成!")
            ###################################################
            print("#"*100)
            return self.reply["2"] %res

    # 编码首句请求的代码函数
    def first_sentence(self):
        ###################################################
        # 能够打印该信息, 说明进入了首句处理函数并马上进行neo4j查询
        print("first_sentence------>该用户近期首次发言, 不必请求模型服务, 准备请求neo4j查询服务!")    # 此处打印信息, 说明程序逻辑进入了首句处理函数, 并且马上要进行neo4j查询
        ###################################################
        s = query_neo4j(self.text)  # 直接查询neo4j图数据库, 并得到疾病名称列表的结果
        ###################################################
        # 能够打印该信息, 说明已经完成neo4j查询.
        print("first_sentence------>neo4j查询服务请求成功, 返回结果:", s)    # 此处打印信息, 说明已经成功完成了neo4j查询服务
        ###################################################
        if not s:   # 判断如果结果为空列表, 再次访问百度机器人
            return unit_chat(self.text)
        self.r.hset(str(self.uid), "previous_d", str(s))    # 将查询回来的结果存储进redis, 并且做为下一次访问的"上一条语句"previous
        self.r.expire(str(self.uid), ex_time)   # 设置数据库的过期时间
        res = ",".join(s)   # 将列表转换为字符串, 添加进规则对话模板中返回给用户
        ###################################################
        # 能够打印该信息, 说明neo4j查询后有结果并将使用规则对话模版.
        print("first_sentence------>使用规则对话生成模板进行返回对话的生成!")   # 此处打印信息, 说明neo4j查询后有结果并且非空, 接下来将使用规则模板进行对话生成
        ###################################################
        return self.reply["2"] %res

代码位置: /data/doctor_online/main_serve/app.py

3.4 第四步: 编写服务中的主函数

# 定义主要逻辑服务的主函数
@app.route('/v1/main_serve/', methods=["POST"]) # 首先设定主要逻辑服务的路由和请求方法
def main_serve():
    ###################################################
    # 能够打印该信息, 说明werobot服务发送请求成功.
    print("/v1/main_serve------>已经进入主要逻辑服务, werobot服务正常运行!") # 此处打印信息, 说明werobot服务成功的发送了请求
    ###################################################
    uid = request.form['uid']   # 接收来自werobot服务的相关字段, uid: 用户唯一标识
    text = request.form['text'] # 接收来自werobot服务的相关字段,  text: 用户输入的文本信息
    r = redis.StrictRedis(connection_pool=pool) # 从redis连接池中获得一个活跃的连接
    previous = r.hget(str(uid), "previous") # 获取该用户上一次说的话(注意: 可能为空)
    r.hset(str(uid), "previous", text)   # 将当前输入的text存入redis, 作为下一次访问时候的"上一句话"
    ###################################################
    # 能够打印该信息, 说明redis能够正常读取和写入数据.
    print("/v1/main_serve------>已经完成了初次会话管理, redis运行正常!")    # 此处打印信息, 说明redis能够正常读取数据和写入数据
    ###################################################
    reply = json.load(open(reply_path, "r"))    # 将规则对话模板的文件Load进内存
    handler = Handler(uid, text, r, reply)  # 实例化Handler类
    if previous:    # 如果上一句话存在, 调用非首句服务函数
        return handler.non_first_sentence(previous)
    else:   # 如果上一句话不存在, 调用首句服务函数
        return handler.first_sentence()

3.5 第五步: 使用gunicorn启动服务

  • -w 代表开启的进程数, 我们只开启一个进程
  • -b 服务的IP地址和端口
  • app:app 是指执行的主要对象位置, 在app.py中的app对象
gunicorn -w 1 -b 0.0.0.0:5000 app:app

代码位置: 在/data/doctor_online/main_serve/路径下执行.

3.6 第六步: 编写测试脚本并进行测试

编写测试脚本:

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病,铍中毒, 再想想还有更多的症状吗?

八、句子主题相关性判断任务

1、任务介绍与模型选用

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

选用的模型及其原因:

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

2、训练数据集

训练数据集的样式:

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

数据集的相关解释:

  • 数据集中的第一列代表标签, 1为正标签, 代表后面的两句话是在讨论同一主题. 0为负标签, 代表后面的两句话不相关.
  • 数据集中的第二列是用户回复的文本信息, 第三列是与上一句相关或不相关的文本.
  • 正负样本的比例是1:1左右

数据集所在位置: /data/doctor_online/bert_serve/train_data.csv

数据集来源及其扩充方式:

  • 来源: 正样本数据来自网络医患在线问答的真实语料. 负样本来自其他使用其他问答语料的回复信息, 保证两段文本不相关.
  • 扩充方式: 根据来源, 可通过数据抓取技术对语料集进行扩充.

3、BERT中文预训练模型

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

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

使用BERT中文预训练模型对两个句子进行编码:

import torch
from transformers import BertModel, BertTokenizer

# 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')   # 使用torch.hub加载bert中文模型

# 使用离线bert模型
tokenizer = BertTokenizer.from_pretrained('/opt/huggingface_pretrained_model/bert-base-chinese')    # 加载bert的分词器
model = BertModel.from_pretrained('/opt/huggingface_pretrained_model/bert-base-chinese')    # 加载bert模型,这个路径文件夹下有bert_config.json配置文件和model.bin模型权重文件

# 使用bert中文模型对输入的“文本对”进行编码
def get_bert_encode(text_1, text_2, mark=102, max_len=10):
    """
    :param text_1: 代表输入的第一句话
    :param text_2: 代表输入的第二句话
    :param mark: 分隔标记, 是预训练模型tokenizer本身的标记符号, 当输入是两个文本时, 得到的index_tokens会以102进行分隔
    :param max_len: 文本的允许最大长度, 也是文本的规范长度即大于该长度要被截断, 小于该长度要进行0补齐
    :return 输入文本的bert编码
    """
    indexed_tokens = tokenizer.encode(text_1, text_2)   # 使用tokenizer的encode方法对输入的两句文本进行字映射.
    # 准备对映射后的文本进行规范长度处理即大于该长度要被截断, 小于该长度要进行0补齐
    k = indexed_tokens.index(mark)  # 先找到分隔标记的索引位置
    if len(indexed_tokens[:k]) >= max_len:  # 首先对第一句话进行长度规范因此将indexed_tokens截取到[:k]判断
        indexed_tokens_1 = indexed_tokens[:max_len] # 如果大于max_len, 则进行截断
    else:
        indexed_tokens_1 = indexed_tokens[:k] + (max_len-len(indexed_tokens[:k]))*[0]   # 否则使用[0]进行补齐, 补齐的0的个数就是max_len-len(indexed_tokens[:k])
    if len(indexed_tokens[k:]) >= max_len:  # 同理对第二句话进行规范长度处理, 因此截取[k:]
        indexed_tokens_2 = indexed_tokens[k:k+max_len]  # 如果大于max_len, 则进行截断
    else:
        indexed_tokens_2 = indexed_tokens[k:] + (max_len-len(indexed_tokens[k:]))*[0]   # 否则使用[0]进行补齐, 补齐的0的个数就是max_len-len(indexed_tokens[:k])
    indexed_tokens = indexed_tokens_1 + indexed_tokens_2    # 最后将处理后的indexed_tokens_1和indexed_tokens_2再进行相加
    segments_ids = [0]*max_len + [1]*max_len    # 为了让模型在编码时能够更好的区分这两句话, 我们可以使用分隔ids【它是一个与indexed_tokens等长的向量, 0元素的位置代表是第一句话,1元素的位置代表是第二句话, 长度都是max_len】
    # 将segments_ids和indexed_tokens转换成模型需要的张量形式
    segments_tensor = torch.tensor([segments_ids])
    tokens_tensor = torch.tensor([indexed_tokens])
    with torch.no_grad():   # 模型不自动求解梯度
        model_result = model(tokens_tensor, token_type_ids=segments_tensor)    # 使用bert model进行编码, 传入参数tokens_tensor和segments_tensor得到encoded_layers
        encoded_layers = model_result[0]
    return encoded_layers

if __name__=="__main__":
    text_1 = "人生该如何起头"
    text_2 = "改变要如何起手"
    encoded_layers = get_bert_encode(text_1, text_2)
    print("encoded_layers.shape = {0}".format(encoded_layers.shape))
    print("encoded_layers = {0}".format(encoded_layers))

代码位置: /data/doctor_online/bert_serve/bert_chinese_encode.py

输出效果:

encoded_layers.shape = torch.Size([1, 20, 768])
encoded_layers = 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]]])

4、微调模型【一个普通神经网络模型,只不过其输入是BERT预训练模型(作为word2vec使用)的输出】

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

构建全连接微调模型的代码分析:

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

CHAR_SIZE=20    # 两句话合并后的最大长度
EMBEDDING_SIZE=768  # BERT编码器的输出维度
DROPOUT=0.2

# 定义微调网络的类
class Net(nn.Module):
    def __init__(self, char_size=CHAR_SIZE, embedding_size=EMBEDDING_SIZE, dropout=DROPOUT):
        """
        :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__()
        self.char_size = char_size
        self.embedding_size = embedding_size

        self.dropout = nn.Dropout(p=dropout)    # 实例化Dropout层
        self.fc1 = nn.Linear(char_size*embedding_size, 8)   # 实例化第一个全连接层
        self.fc2 = nn.Linear(8, 2)  # 实例化第二个全连接层【最终输出的维度为2,表示二分类】

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

# 测试
if __name__=="__main__":
    x = torch.randn(1, 20, 768)
    net = Net(char_size=CHAR_SIZE, embedding_size=EMBEDDING_SIZE, dropout=DROPOUT)
    nr = net(x)
    print("nr = {}".format(nr))

代码位置: /data/doctor_online/bert_serve/finetuning_net.py

输出效果:

nr = tensor([[0.2582, 0.5430]], grad_fn=<ReluBackward0>)

5、进行模型训练

进行模型训练的步骤:

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

5.1 第一步: 构建数据加载器函数

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


# 定义数据加载器构造函数【return: 训练数据生成器, 验证数据的生成器, 训练数据的大小, 验证数据的大小】
def data_loader(data_path, batch_size, split=0.2):  # data_path: 训练数据的路径; batch_size: 训练集和验证集的批次大小; split: 训练集和验证集的划分比例
    data = pd.read_csv(data_path, header=None, sep="\t")    # 首先读取数据
    print("数据集的正负样本数量:", dict(Counter(data[0].values))) # 打印一下整体数据集上正负样本的数量
    data = shuffle(data).reset_index(drop=True) # 要对读取的数据进行散乱顺序的操作
    valid_data , train_data = data[:int(len(data) * split)], data[int(len(data) * split):]  # 划分训练集和验证集
    if len(valid_data) < batch_size:    # 保证验证集中的数据总数至少能够满足一个批次
        raise("Batch size or split not match!")
    def _loader_generator(data):    # 定义获取每个批次数据生成器的函数【data: 训练数据或者验证数据】
        for batch in range(0, len(data), batch_size):   # 以每个批次大小的间隔来遍历数据集
            batch_encoded, batch_labels = [], []  # 初始化batch数据的存放张量列表
            for item in data[batch: batch + batch_size].values.tolist():    # 逐条进行数据的遍历
                encoded = get_bert_encode(item[1], item[2]) # 对每条数据进行bert预训练模型的编码
                batch_encoded.append(encoded)   # 将编码后的每条数据放进结果列表中
                batch_labels.append([item[0]])  # 将标签放入结果列表中
            encoded = reduce(lambda x, y: torch.cat((x, y), dim=0), batch_encoded)  # 使用reduce高阶函数将列表中的数据转换成模型需要的张量形式【encoded的形状[batch_size, 2 * max_len, embedding_size]】
            labels = torch.tensor(reduce(lambda x, y: x + y, batch_labels))
            yield (encoded, labels) # 以生成器的方式返回数据和标签
    return _loader_generator(train_data), _loader_generator(valid_data), len(train_data), len(valid_data)


if __name__=="__main__":
    data_path = "./train_data.csv"  # 数据所在路径
    batch_size = 2 # 定义batch_size大小
    train_data_labels, valid_data_labels, train_data_len, valid_data_len = data_loader(data_path, batch_size)

    print("next(train_data_labels) = \n{}".format(next(train_data_labels)))
    print("next(valid_data_labels) = \n{}".format(next(valid_data_labels)))
    print("train_data_len = {}".format(train_data_len))
    print("valid_data_len = {}".format(valid_data_len))

代码位置: /data/doctor_online/bert_serve/train.py

输出效果:

ssh://root@47.93.247.255:22/root/anaconda3/bin/python -u /data/doctor_online/bert_server/train.py
数据集的正负样本数量: {
    
    1: 14015, 0: 13717}
next(train_data_labels) = 
(
	tensor([[[-0.6653,  0.2601,  0.9937,  ..., -0.1491,  0.3399,  0.2535],
         [-0.2061, -0.1209,  0.1488,  ..., -0.3750, -0.9512, -0.0034],
         [-0.1356, -0.7883, -0.5761,  ...,  0.8373, -0.3535,  0.2445],
         ...,
         [ 0.4678,  0.5556, -0.3037,  ..., -0.3734, -0.7834,  0.3424],
         [ 0.0046, -0.4421,  0.7853,  ...,  0.2060, -0.4776, -0.3887],
         [ 0.0257,  0.5114,  1.0396,  ...,  1.3566, -0.5437,  0.2069]],

        [[-0.2804,  0.9757,  0.0050,  ..., -0.1347,  0.2520, -0.3765],
         [-0.3613,  0.0995, -0.9324,  ...,  0.3289,  0.1141,  0.1697],
         [ 1.3212, -0.6421, -1.8825,  ...,  1.9326,  0.7786,  0.8171],
         ...,
         [-0.6239, -0.2365,  0.4187,  ...,  0.3874,  0.5394, -0.2026],
         [-0.4096, -0.5674,  0.5467,  ...,  0.7272,  0.4930, -0.3590],
         [ 0.4074, -0.1150,  1.5854,  ...,  0.5656,  0.5695, -0.0250]]]), 
	tensor([0, 0])
)
next(valid_data_labels) = 
(
	tensor([[[-0.6312,  0.9880,  0.5836,  ...,  1.2853,  0.2636,  0.1650],
         [ 1.0966, -0.0955, -1.0984,  ...,  0.6675, -0.3923,  0.9094],
         [ 0.9568, -0.5999, -0.2891,  ...,  2.0106, -0.4649,  0.1127],
         ...,
         [ 0.7498, -0.5518, -0.0848,  ...,  0.4047,  0.2773,  0.0601],
         [ 0.7689,  0.2915,  0.1599,  ...,  0.5232, -0.4128,  0.3322],
         [ 0.5342,  0.6842,  0.1397,  ...,  0.3272, -0.1759, -0.4629]],

        [[-0.7537,  0.5432,  0.0223,  ..., -0.2642, -0.2811, -0.2975],
         [-0.7623, -0.5313, -0.2014,  ..., -0.7371, -0.8586, -0.2538],
         [ 0.1591, -0.6450, -0.0276,  ...,  0.7146,  0.3568, -0.5812],
         ...,
         [ 0.3970,  0.5306, -1.2850,  ..., -0.4080,  0.5966, -0.3913],
         [ 0.0565, -0.0624, -0.3680,  ..., -0.7800, -0.1388, -0.5811],
         [ 0.2502,  0.3158, -0.1015,  ..., -0.4377, -0.0445, -0.2227]]]), 
	tensor([0, 0])
)
train_data_len = 22186
valid_data_len = 5546

Process finished with exit code 0

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

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


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


# 训练函数【return: 整个训练过程的平均损失和, 正确标签数量的累加和】
def train(train_data_labels):   # train_data_labels: 代表训练数据和标签的生成器对象
    train_running_loss, train_running_acc = 0.0, 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_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    # 整个循环结束后, 训练完毕, 得到损失和以及正确样本的总量

代码位置: /data/doctor_online/bert_serve/train.py

5.3 第三步: 模型验证函数

# 验证函数【return: 整个验证过程中的平均损失和和正确标签的数量和】
def valid(valid_data_labels):   # valid_data_labels: 代表验证数据和标签的生成器对象
    valid_running_loss, valid_running_acc = 0.0, 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_loss.item() # 累加损失和
            valid_running_acc += (valid_outputs.argmax(1) == valid_labels).sum().item() # 累加正确预测的标签数量
    return valid_running_loss, valid_running_acc    # 返回整个验证过程中的平均损失和, 累加的正确标签数量

代码位置: /data/doctor_online/bert_serve/train.py

5.4 第四步: 调用训练和验证函数并打印日志

# 定义每个轮次的损失和准确率的列表初始化, 用于未来画图
all_train_losses, all_valid_losses,  all_train_acc, all_valid_acc= [], [], [], []

for epoch in range(EPOCHS):
    print("="*30, "Epoch {0}".format(epoch),"="*30)
    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_average_loss, valid_average_loss = train_running_loss * batch_size / train_data_len, valid_running_loss * batch_size / valid_data_len # 计算平均损失, 每个批次的平均损失之和乘以批次样本数量, 再除以本轮次的样本总数
    train_average_acc, valid_average_acc = train_running_acc / train_data_len, valid_running_acc / valid_data_len   # 计算准确率, 本轮次总的准确样本数除以本轮次的总样本数
    # 接下来将4个值添加进画图的列表中
    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)
    time_ = int(time.time())  # 保存模型时间
    MODEL_PATH = './saved_model/BERT_net_%d_%d.pth' % (epoch,time_)  # 设置保存路径和模型名称
    torch.save(net.state_dict(), MODEL_PATH)  # 保存模型
print("Finished Training.")

代码位置: /data/doctor_online/bert_serve/train.py

输出效果:

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

5.5 第五步: 绘制训练和验证的损失和准确率对照曲线

# 导入制图工具包
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") # 绘制验证损失曲线, 同时将颜色设置为红色
x_major_locator = MultipleLocator(1)    # 定义横坐标间隔对象, 间隔等于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") # 绘制验证准确率曲线, 同时将颜色设置为红色
x_major_locator = MultipleLocator(1)    # 定义横坐标间隔对象, 间隔等于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")    # 保存图片

代码位置: /data/doctor_online/bert_serve/train.py

训练和验证损失对照曲线:

在这里插入图片描述

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

在这里插入图片描述

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


5.6 第六步: 模型保存

# 模型保存时间
time_ = int(time.time())
# 保存路径
MODEL_PATH = './model/BERT_net_%d.pth' % time_
# 保存模型参数
torch.save(rnn.state_dict(), MODEL_PATH)

代码位置: /data/doctor_online/bert_serve/train.py

输出效果:

  • 在/data/bert_serve/路径下生成BERT_net_ + 时间戳.pth的文件.

6、模型部署

使用Flask框架进行模型部署的步骤:

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

6.1 第一步: 部署模型预测代码

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())

代码位置: /data/doctor_online/bert_serve/app.py

6.2 第二步: 启动服务

进入 /data/doctor_online/bert_serve 目录

gunicorn -w 1 -b 0.0.0.0:5001 app:app 

6.3 第三步: 进行测试

测试脚本:

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)

代码位置: /data/doctor_online/bert_serve/test.py

运行脚本:

python test.py

输出效果:

预测样本: 人生该如何起头 | 改变要如何起手
预测结果: 1

九、系统联调与测试

1、启动各个服务组件

系统架构图:

在这里插入图片描述

系统在线部分的主要服务:

  • werobot服务
  • 主要逻辑服务
  • 句子相关模型服务
  • redis服务(会话管理)
  • neo4j服务(图数据查询)

说明: 系统联调与测试是对系统在线部分服务的联调与测试, 不包含离线部分的内容.

启动系统在线部分的服务:

  • 以挂起的方式启动werobot服务
  • 使用supervisor启动主要逻辑服务及其redis服务
  • 以挂起的方式启动子相关模型服务
  • 启动和查看neo4j服务(默认已启动)

1.1 以挂起的方式启动werobot服务

nohup python /data/doctor_online/wr.py &

运行位置: /data/doctor_online目录下

通过端口查看挂起的服务进程:

通过yum安装lsof命令

# 通过yum安装lsof命令
sudo yum install lsof -y
# 查看80端口的进程
lsof -i:80

1.2 启动和查看neo4j服务(图数据查询)

neo4j start
# 查看服务启动状态:
neo4j status

1.3 使用supervisor启动主要逻辑服务及其redis服务

supervisor配置文件简要分析:

...

; 使用gunicorn启动基于Flask框架的主要逻辑服务 
[program:main_server]
command=gunicorn -w 1 -b 0.0.0.0:5000 app:app                    ; the program (relative uses PATH, can take args)
stopsignal=QUIT               ; signal used to kill process (default TERM)
stopasgroup=false             ; send stop signal to the UNIX process group (default false)
killasgroup=false             ; SIGKILL the UNIX process group (def false)
stdout_logfile=./log/main_server_out      ; stdout log path, NONE for none; default AUTO
stdout_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)
stderr_logfile=./log/main_server_error        ; stderr log path, NONE for none; default AUTO
stderr_logfile_maxbytes=1MB   ; max # logfile bytes b4 rotation (default 50MB)

; 启动redis服务,作为会话管理数据库
[program:redis]
command=redis-server

文件位置: /data/doctor_online/main_server/supervisord.conf

启动服务:

# 使用supervisord命令, 读取指定目录下的文件
(base) [root@whx main_server]# supervisord -c supervisord.conf
(base) [root@whx main_server]# 

查看启动的服务状态

supervisorctl status all

输出效果:

main_server                      RUNNING   pid 31609, uptime 0:32:20
redis                            RUNNING   pid 31613, uptime 0:32:18

如果程序都处于STOP状态,则 supervisorctl start all开启全部服务

(base) [root@whx main_server]# supervisorctl start all
main_server: started
redis: started
(base) [root@whx main_server]# supervisorctl status all
main_server                      RUNNING   pid 2881, uptime 0:00:10
redis                            RUNNING   pid 2882, uptime 0:00:10
(base) [root@whx main_server]# 

1.4 以挂起的方式启动句子相关模型服务

编写启动脚本start.sh

# 脚本中是使用gunicorn启动服务的命令
gunicorn -w 1 -b 0.0.0.0:5001 app:app

代码位置: /data/doctor_online/bert_serve/start.sh

以挂起的方式启动服务

nohup sh /data/doctor_online/bert_serve/start.sh &

运行位置: /data/doctor_online/bert_serve/目录下.

通过端口查看挂起的服务进程

# 通过yum安装lsof命令
sudo yum install lsof -y


# 查看5001端口的进程
lsof -i:5001

2、进行测试

  • 第一步: 明确测试说明.
  • 第二步: 添加打印测试内容.
  • 第三步: 重新启动主要逻辑服务.
  • 第四步: 进行数据流测试并查看打印日志.

2.1 第一步: 测试说明

因为主要逻辑服务是所有在线服务的中心服务(该服务将接收或发送请求给其他服务), 因此我们的测试打印信息都在主要逻辑服务中进行.

2.2 第二步: 添加打印测试内容

代码位置: /data/doctor_online/main_serve/app.py

2.3 第三步: 重新启动主要逻辑服务

supervisorctl restart all

运行位置: /data/doctor_online/main_serve

2.4 第四步: 进行数据流测试并查看打印日志

  • 测试请求1: 用户关注公众号后首次发送一些症状描述.
  • 测试请求2: 首次发送后用户继续发送一些症状描述.

2.4.1 测试请求1

用户关注公众号后首次发送一些症状描述.

对应数据流:

  • werobot服务–>请求主要逻辑服务–>主要逻辑服务中请求redis服务–>请求neo4j查询服务–>使用规则对话模版/UnitAPI.

对应操作:

  • 关注公众号(使用新用户), 发送"我最近有些腹痛".

查看主要逻辑服务日志:

cat /data/doctor_online/main_serve/log/main_server_out

日志打印结果:

## 打印如下结果说明测试成功!
已经进入主要逻辑服务, werobot服务运行正常!
已经完成初次会话管理, redis运行正常!
该用户近期首次发言, 不必请求模型服务, 准备请求neo4j查询服务!
neo4j查询服务请求成功, 返回结果为: ['癫痫', '小儿糖尿病', '肾上腺危象', '异位急性阑尾炎', '急性胆囊炎']
使用规则对话模版进行返回生成的对话!

2.4.2 测试请求2

首次发送后用户继续发送一些症状描述.

对应数据流:

  • werobot服务–>请求主要逻辑服务–>主要逻辑服务中请求redis服务–>请求句子相关模型服务–>请求neo4j查询服务–>使用规则对话模版/UnitAPI.

对应操作:

  • 发送"我最近有些腹痛"后, 继续发送"并且左腹部有一些红点".

查看主要逻辑服务日志:

cat /data/doctor_online/main_serve/log/main_server_out

日志打印结果:

## 打印如下结果说明测试成功!(使用UnitAPI返回结果)
已经进入主要逻辑服务, werobot服务运行正常!
已经完成初次会话管理, redis运行正常!
准备请求句子相关模型服务!
句子相关模型服务请求成功, 返回结果为: 1
请求模型服务后, 准备请求neo4j查询服务!
neo4j查询服务请求成功, 返回结果: []

注意: 打印日志若不能即时出现, 重新启动主要逻辑服务即可.

猜你喜欢

转载自blog.csdn.net/u013250861/article/details/115054592