第四章:AR测试系统

4.1 背景需求与分析


学习目标

  • 了解该系统的背景需求。
  • 通过对需求分析明确交付系统要求。

背景需求

  • 考试中心AR测试批阅部门每天需要完成大量的AR测试批改任务,这些AR测试来自于我们向全国大学提供教材附件,我们必须帮助所有使用该教材的学生完成最后的在线模拟考试,并快速发布成绩。而传统的人工批阅方式无法满足日益增长的业务需求,而且,人工批阅时由于教师精力有限,处在后期批阅的AR测试会出现大量的漏批,错批,使考试成绩出现偏差而导致诸多不公平的事件。
  • 正因如此,考试中心希望我们能够帮助解决这一问题,利用人工智能等相关技术对AR测试中典型的问题进行解决,一般情况下,我们的AR测试题目分为三种大的类型:选择题,填空题和简答题。对于选择题,因为有明确的答案,后台系统已经能够完成自动批阅。而对于填空题和简答题则具有多变性,无法使用任何统一的规则来覆盖,因此也是我们研究的重点(本教程将针对填空题展开)。我们称这一系统为考试中心智能批阅系统。

需求分析

通过上述背景需求,我们首先明确以下几点(针对填空题):

  • 系统输入: 学生答案和正确答案,以及本题的分数。
  • 系统输出: 该题目的最终得分,最好还可以给出这个得分的置信度等级(用于可视化)。
  • 在线服务要求: AR测试批阅是一个离线过程,但是为了更好的做系统间的对接,我们仍然会提供REST服务,我们同样希望响应时间尽可能的短。

以上这些也是我们最终需要交付系统的要求。

  • 问题简要分析:
    • 首先如果学生作答与标准答案相同,则获得满分。学生答案为空字符串为零分。
    • 如果学生作答与标准答案不同,则可能获得满分,零分,中间分。
    • 在这里,模型的主要作用就是判断:当学生作答与标准答案不同时,学生的最终得分。
    • 初版为了减轻模型预测难度,一旦判定为中间分,则取满分的1/2作为中间分。因此,这是可将该任务视为文本对多分类(三个类别)任务。

小节总结

  • 学习了系统背景需求:
    • 利用人工智能等相关技术对给定的学生答案和正确答案进行对比,最终给出可能得分。
  • 学习了交付系统要求:
    • 明确了输入,输出和在线服务要求,以及问题的简要分析。

4.2 整体解决方案初定


学习目标

  • 了解初始整体解决方案的作用。
  • 了解初始整体解决方案的各个步骤。

初始整体解决方案的作用

初始整体解决方案一般是在与产品,运营讨论需求后制定技术解决方案。在这个时间点上,AI工程师往往还没有拿到真实数据,只能通过需求描述来假设数据情况,并根据这种情况制定方案。

  • 该方案主要基于我们之前的类似项目经验,一方面帮助我们初步梳理整个处理思路和流程,另一方面给合作部门人员彰显我们是可以胜任这项工作的(这对于团队之间的合作来讲至关重要,我们需要先有一定的表示才能获得信任)。
  • 初始整体解决方案一般是一个细节不够完善的方案,但是它已经有了一个大体的解决框架,在之后的真实数据下和开发过程中不断优化。

初始整体解决方案的环节

  • 第一步: 明确问题并提出数据要求
  • 第二步: 对原始数据进行数据分析
  • 第三步: 使用规则进行分数判断
  • 第四步: 使用BERT模型进行微调判断最终得分
  • 第五步: 模型部署服务概述
  • 第六步: 总结与改进

第一步: 明确问题并提出数据要求

  • 明确问题:
    • 根据学生答案和正确答案以及该题的分数,返回该学员的最终得分(以及置信等级)。

  • 数据要求:

    • 为了保证数据时效性,要求数据方提供近期(一年内)考试批阅数据,包括学生答案,正确答案,学生得分,和该题总分。
    • 我们要求提供以下类型的近期数据至少10万条,共30万条原始语料:

      • 学生答案与正确答案不同,得了满分;
      • 学生答案与正确答案不同,得了中间分;
      • 学生答案与正确答案不同,得了零分。
    • 数据量是可以取的更多的,但是近期的数据更具有代表性,反应的这个阶段题目的特点。


第二步: 对原始数据进行数据分析

  • 数据分析:

    • 1,预览分析

      • 作用:判断数据是否应该进行清洗以及重点应该清洗哪些地方。
    • 2,统计文本长度分布

      • 作用: 通过分布情况决定长度合法性检验的范围(一般填空题长度都是较短的,过长的答案一般是数据异常的情况)。
    • 3,统计数据清洗后类别的数量分布

      • 作用: 用于确定类别标签的数量,用于分析模型可能存在缺陷的原因。

第三步: 使用规则进行分数判断

  • 当前比较明确的规则:
    • 学生答案与正确答案完全相同,则为满分。
    • 学生答案为空,则为零分。

第四步:使用BERT模型进行微调判断最终得分

  • BERT模型天生具备以两段文本为输入,输出一个预测标签的能力,因此这里选择在BERT上进行微调。


第五步: 模型部署服务概述

  • 总体服务架构设计
    • 使用基于Django的服务框架。
    • 使用nginx作为反向代理和负载均衡。
    • 使用supervisor作为单服务守护与监控。
    • 使用uwsgi作为高性能web server。

  • 模型服务封装
    • 基于tensorflow/keras框架开发的模型使用tf-serving进行封装,以保证服务健壮性以及模型热更新。
    • 基于pytorch框架开发的模型使用flask框架进行封装,使用交替双服务保证模型热更新。

  • 系统联调与测试
    • 与外界服务使用REST API(http)进行交互。
    • 输入与输出为规范json格式。
    • 根据实际接口调用情况,进行并发压力测试。
    • 灰度发布,进行可用性测试。

  • 服务器资源

    • 模型训练服务器:

      • CPU: 8C,32G内存,100G硬盘
      • GPU: GTX 1080Ti / Tesla T4
    • 模型部署服务器:

      • CPU: 8C,16G内存,100G硬盘,1M带宽

第六步: 总结与改进

  • 上述方案皆基于与讨论中心的假设情况,而在实际数据中可能存在大量噪音数据,之后将通过对真实数据的分析提出更加具有普适性的解决方案。

小节总结

  • 学习了初始整体解决方案的作用:
    • 一方面帮助我们初步梳理整个处理思路和流程,另一方面给合作部门人员彰显我们是可以胜任这项工作的。

  • 学习了初始整体解决方案的各个步骤:
    • 第一步: 明确问题并提出数据要求
    • 第二步: 对原始数据进行数据分析
    • 第三步: 使用规则进行分数判断
    • 第四步: 使用BERT模型进行微调判断最终得分
    • 第五步: 模型部署服务概述
    • 第六步: 总结与改进

4.3 整体解决方案实施与调整


学习目标

  • 掌握整体解决方案的实施步骤和代码实现。
  • 掌握根据真实数据情况作出的一些方案调整和代码实现。

整体解决方案的实施步骤

  • 第一步: 获取指定数据并进行数据清洗和分析
  • 第二步: 根据分析结果进行模型选择与训练代码实现
  • 第三步: 整体服务部署与联调测试

第一步: 获取指定数据并进行数据分析

根据之前的”数据要求”,请参见初始整体解决方案的环节: 明确问题并提出数据要求,我们将从数仓人员手中获得指定数据,虽然数据数量较多,但是因为文本内容大多都较短,因此最终以excel的形式给到我们。


  • 数据说明:
    • 通过上图表格,我们可以看到共有4列(4个字段),分别是”学生答案”(studentanswer),”正确答案”(trueanswer),”该题总分”(truecore),”学生得分”(studentscore)。

根据初始整体解决方案的环节: 对原始数据进行数据分析进行数据分析:

  • 预览分析与数据清洗:
    • 满分不同的数据语料:

  • 中间分的数据语料:

  • 零分的数据语料:
  • 分析结果:
    • 数据中存在大量异常字符,如空白符,异常html标签等,同时在零分和中间分中还有很多学生答案与正确答案相同的情况,需要进行过滤。

  • 填空题类型可划分为: 单项填空和多项填空,对于多项填空问题一般允许自由顺序,因此需要给模型一个特殊标记以区分该类型。

  • 进行数据清洗:


# 使用pandas读取excel数据
# pip install pandas==1.0.5
import pandas as pd
import re

def _etl(string):
“””进行数据清洗”””
# 去除任意空白符并进行小写
string = re.sub(“\s+|;|【|】|“|”|’|‘“, “”, string).lower()
# 找到异常的html并进行去除, 如,
m = re.findall(“<.*?>”, string)
for mm in m + [
“<”,
“>”,
“ ”,
“””,
“"”,
“&”,
““”,
“…”,
]:
# 使用replace方法进行去除
string = string.replace(mm, “”)
return string

def _processor(df):
“””对表格中的某个列进行处理”””
# 存储列的处理结果
result = []
# 遍历该列中的每个值
for item in df.values:
try:
# 存储每个值的处理结果
res = []
# 对于多项填空需要进行循环
# 单项填空只循环一次
for string in eval(item):
# 对每个空中的值进行etl处理
string = _etl(string)
res.append(string)
# 并且每个填空内容用[PAD]分割
result.append(“[PAD]”.join(res))
except Exception as e:
print(“Warning:” + str(e))
# 如果出错,说明内容异常,直接作为空字符串
result.append(“”)
return result

def processor(df, path):
“””对每张表格进行处理”””
# 处理表中的每个列
df1 = _processor(df[“studentanswer”])
df2 = _processor(df[“trueanswer”])

  1. # 因为需要将处理后的两个列进行重新合并<br /> # 因此每个列的长度必须相等<br /> assert len(df1) == len(df2)<br /> # 合并成新的DataFrame<br /> df = pd.DataFrame({"studentanswer": df1, "trueanswer": df2})<br /> # 打印处理后的数据量<br /> # print("基本处理后的数量:", len(df))
  2. # 对于零分/中间分/满分不同数据,<br /> # 即真实答案与学生答案不可以相同(相同可能由于教师疏忽)<br /> # 这里注意因为我们是在基本处理后进行去除相同答案,<br /> # 要求在最终的服务规则里也加入基础处理后的规则判断。<br /> df = df.drop(df[df["studentanswer"] == df["trueanswer"]].index)
  3. # print("去除相同答案后的数量:", len(df))<br /> # 去除任意一行为空值的行,<br /> # 如果学生/正确答案输入为空,会在最初的基本规则上过滤<br /> df = df.drop(df[df["studentanswer"] == ""].index)<br /> df = df.drop(df[df["trueanswer"] == ""].index)<br /> # print("去除空字符串后的数量:", len(df))<br /> df.to_csv(path, index=False, sep="\t")
  • 调用:


# 读取并处理零分数据
zero_path = “./yxb_data/zero.xlsx”
zero_target_path = “./yxb_data/zero_target.tsv”
zero_df = pd.read_excel(zero_path, sheet_name=”Sheet4”)
# zero_df = pd.read_excel(zero_path)
processor(zero_df, zero_target_path)

读取并处理满分不同数据
perfect_path = “./yxb_data/perfect_diff.xlsx”
perfect_target_path = “./yxb_data/perfect_target.tsv”
perfect_df = pd.read_excel(perfect_path, sheet_name=”Sheet4”)
processor(perfect_df, perfect_target_path)

读取并处理中间分数据
middle_path = “./yxb_data/middle.xlsx”
middel_target_path = “./yxb_data/middle_target.tsv”
middle_df = pd.read_excel(middle_path, sheet_name=”Sheet4”)
processor(middle_df, middel_target_path)


  • 代码位置:
    • /home/YXB/data_processor.py

  • 输出效果:
    • 在指定目录下我们将得到zero_target.tsv,perfect_diff.xlsx,middle_target.tsv。

  • 统计文本长度分布的实现:


import seaborn as sns
import matplotlib.pyplot as plt

设置显示风格
plt.style.use(‘fivethirtyeight’)

def plot_length(path, title):
df = pd.read_csv(path, sep=”\t”)
# 分别在数据中添加新的句子长度列
df[“sentence_length_student”] = list(map(lambda x: len(str(x)),
df[“studentanswer”]))
df[“sentence_length_true”] = list(map(lambda x: len(str(x)),
df[“trueanswer”]))
print(title + “—绘制学生答案长度分布图:”)
sns.countplot(“sentence_length_student”, data=df)
# 主要关注count长度分布的纵坐标, 不需要绘制横坐标
# 横坐标范围通过dist图进行查看
plt.xticks([])
plt.show()
sns.distplot(df[“sentence_length_student”])
# 主要关注dist长度分布横坐标, 不需要绘制纵坐标
plt.yticks([])
plt.show()
print(title + “—绘制真实答案长度分布图:”)
sns.countplot(“sentence_length_true”, data=df)
plt.xticks([])
plt.show()
sns.distplot(df[“sentence_length_true”])
plt.yticks([])
plt.show()
df.to_csv(path, index=False, sep=”\t”)


  • 代码位置:
    • /home/YXB/data_processor.py

  • 调用:


# 中间分长度分布:
title = “中间分”
middle_path = “./yxb_data/middle_target.tsv”
plot_length(middle_path, title)

满分不同长度分布:
title = “满分不同”
perfect_path = “./yxb_data/perfect_target.tsv”
plot_length(perfect_path, title)

零分长度分布:
title = “零分”
zero_path = “./yxb_data/zero_target.tsv”
plot_length(zero_path, title)


  • 输出效果:
  • 绘制中间分学生答案数量-长度分布图:
  • 绘制中间分真实答案数量-长度分布图:

  • 绘制满分学生答案数量-长度分布图:
  • 绘制满分真实答案数量-长度分布图:

  • 绘制零分学生答案数量-长度分布图:
  • 绘制零分真实答案数量-长度分布图:
  • 分析:
    • 根据长度分布,大部分的学生答案和真实答案都是在1-50长度之间,为了覆盖更多的可能性,我们可以将长度分别延展至100,也就是说,我们之后将会对训练语料进行过滤,只留下学生答案/真实答案长度<=100的条目,与之对应,意味着模型不再接收长度>100的学生答案/真实答案。

  • 接下来我们将使用上述分析结果对训练数据长度进行限制,同时我们也需要为数据添加标签列,做最后进行模型训练的准备:


def length_limit(path, length, mark):
“””对长度进行限制过滤
Args:
path: 长度限制过滤前的文件路径,同时也是长度限制后的文件路径(覆盖)
length: 长度限制过滤的值
mark: 标签的表示, [‘Y’, ‘N’, ‘M’]
“””
df = pd.read_csv(path, sep=”\t”)
print(“过滤前数量:”, len(df))
# 分别进行过滤掉长度超过限制的数据条目
df = df.drop(df[df[“sentence_length_true”] > length].index)
df = df.drop(df[df[“sentence_length_student”] > length].index)

  1. # 添加标签列,并制定相应的标记<br /> df["label"] = len(df) * [mark]<br /> # 删除长度列,只留下需要的内容<br /> df = df.drop(["sentence_length_student", "sentence_length_true"], axis=1)<br /> df.to_csv(path, index=True, sep="\t")<br /> print("过滤后数量:", len(df))

  • 代码位置:
    • /home/YXB/data_processor.py

  • 调用:


# 中间分调用限制长度函数
middle_path = “./yxb_data/middle_target.tsv”
length = 200
mark = “M”
length_limit(middle_path, length, mark)

满分不同调用限制长度函数
perfect_path = “./yxb_data/perfect_target.tsv”
length = 200
# 添加的标签列
mark = “Y”
length_limit(perfect_path, length, mark)

零分调用限制长度函数
zero_path = “./yxb_data/zero_target.tsv”
length = 200
# 添加的标签列
mark = “N”
length_limit(zero_path, length, mark)

  • 输出效果:
    • 将在该路径下获得新的tsv文件


# 打印过滤前后的具体数量:
# 中间分
过滤前数量: 11236
过滤后数量: 11159

满分
过滤前数量: 90447
过滤后数量: 90302

零分
过滤前数量: 53932
过滤后数量: 53879


  • 标签数量分布:


# 设置背景风格
plt.switch_backend(‘Agg’)

def plot_count(num_list, y_label, num_title, fig_name):
“””绘制数量分布函数
Args:
num_list: 展示的数值列表
y_label: y轴表示, 比如该标签的样本数量
num_title: num_list中每个数值的含义
fig_name: 图片名字
“””
# 初始化画布和轴
fig, ax = plt.subplots()
# 使用数据绘制柱状图
ax.bar([0, 1, 2], num_list)
# 设置纵坐标名称
ax.set_ylabel(y_label)
# 设置横轴刻度
ax.set_xticks([0, 1, 2])
# 设置横轴名称
ax.set_xticklabels(num_title)
# 加入表格线
ax.yaxis.grid(True)
# 设置布局
plt.tight_layout()
# 保存图像并关闭画布
plt.savefig(fig_name)
plt.close(fig)


  • 代码位置:
    • /home/YXB/data_processor.py

  • 调用:


num_list = [11159, 90447, 53932]
y_label = “样本数量”
num_title = [“中间分”, “满分”, “零分”]
fig_name = “score.png”
plot_count(num_list, y_label, num_title, fig_name)


  • 输出效果:

  • 分析:
    • 通过数据清洗后,我们发现数据标签数量分布并不均衡,此时,一方面,可以选择对比较少的数据提出新的数据需求。但实际上,多分类中类别数据不均衡是非常常见的现象,因此我们会直接进入模型训练过程,之后会通过对评估结果再进行分析来明确具体增加的数据要求。

第二步: 根据分析结果进行模型选择与训练代码实现

  • 模型选择:
    • 首先对于离线任务,更注重于评估指标的ACC的提升,通过基础对比实验(迁移学习和自构建模型的对比),选择微调预训练模型的方式进行训练。
    • 鉴于实际语料中涉及的编程符号,中英文较多,这里选择词汇映射范围覆盖更广的bert-Multilingual(这里也可以做一些词汇映射的验证实验),它在102种语言上训练得到。
    • 我们将使用由huggingface基于pytorch实现的bert-Multilingual进行微调,解决多分类问题。

模型的训练与调优过程是我们的主要工作,同时也是需要反复迭代的过程。因此我们独立一章去详细讲解,关于得分判别模型训练与对比实验请参考:得分判别模型训练与对比实验


第三步: 整体服务部署与联调测试

为了完成整体服务部署,我们需要让AI系统与咨询师后端系统进行对接。以往AI中的函数都是在处理一些自定义格式的数据,但是现在我们需要和后端工程师一同定义输入和输出的数据格式,以方便他们来请求我们的接口和使用数据。作为REST API,输入输出的基本格式都应是JSON。

  • 输入的JSON格式:


{
“pid”: “12312”,
“student_answer”: [“alt”],
“true_answer”: [“title”],
“full_marks”: “2”,
}


  • 数据说明:
    • 必须为json格式.
    • 共四个key,分别为:pid, student_answer, true_answer, full_marks
    • pid: 字符串类型,问题的唯一标识
    • student_answer: 列表类型,学生的答案,若为多项填空则[“a”, “b”, “…”]
    • true_answer: 列表类型,正确答案,若为多项填空则[“A”, “B”, “…”]
    • full_marks: 字符串类型,该题的总分.

关于输出则简单许多,输出的JSON格式如下:

{
“status”:”0”,
“pid”:”12312”,
“student_answer”:[
“alt”
],
“true_answer”:[
“title”
],
“score”:”0”,
“confidence”:”H”
}


  • 数据说明:
    • json格式.
    • 共六个key,分别为: status, pid, student_answer, true_answer, score, confidence.
    • status: 服务响应的状态,status为0代表正常,status为1代表异常.
    • pid, student_answer, true_answer与DATA中的含义相同.
    • score: 字符串类型,服务预测的得分.
    • confidence: 得分的置信度,分为三个等级H(高置信度),M(中置信度),L(低置信度).

这样我们就明确了AI整体服务的输入和输出,接下来我们开始搭建这个服务,整个服务框架基于Django,过程可分为一下几个步骤:

  • 第一步: 拷贝服务框架的基本文件
  • 第二步: 编写三个核心文件中的代码内容
  • 第三步: 安装supervisor监控守护工具并启动服务
  • 第四步: 进行联调测试

  • 第一步: 拷贝服务框架的基本文件
    • 我们已经为大家准备好了Django服务的基本文件
    • 注意:需要在/data/目录下安装Anaconda3

  • 文件查看效果, 它们应该在/data/ItcastBrain/路径下:


drwxr-xr-x 4 root root 4096 5月 11 17:21 api
drwxr-xr-x 3 root root 4096 5月 28 16:00 conf
-rw-r—r— 1 root root 180224 5月 9 15:30 db.sqlite3
drwxr-xr-x 5 root root 4096 6月 5 13:37 Yxb
-rw-r—r— 1 root root 0 1月 26 2019 init.py
drwxr-xr-x 2 root root 4096 5月 28 16:42 log
-rwxr-xr-x 1 root root 1501 1月 26 2019 manage.py
-rw-r—r— 1 root root 1643 1月 26 2019 README.md
-rw-r—r— 1 root root 237 6月 1 16:13 requirements.txt
drwxr-xr-x 3 root root 4096 5月 9 15:46 server
drwxr-xr-x 12 root root 4096 2月 6 18:43 static
-rw-r—r— 1 root root 10177 5月 28 16:07 supervisord.conf
drwxr-xr-x 2 root root 4096 5月 9 15:33 supervisord.conf.d
-rw-r—r— 1 root root 6038 6月 1 17:35 test.py


  • 安装必备的工具:


# 确保你的pip是python3下的pip
# 在/data/ItcastBrain/路径下执行:
pip install -r requirements.txt

# 注意:
## The following requirements were added by pip freeze:
## python==3.7.4
Flask==1.1.1
gunicorn==20.0.4
Django==2.1.5
djangorestframework==3.9.1
django-filter==2.1.0
celery==4.1.0
django-celery-beat==1.4.0
flower==0.9.2
uwsgi==2.0.17.1
django-cors-headers==2.4.0
xlrd==1.1.0
seaborn==0.10.1
pandas==1.0.5
torch==1.4.0
transformers==2.5.1


第二步: 编写三个核心文件中的内容
先来了解一下Django服务中的三个文件:

  • urls.py, 位于/data/ItcastBrain/api/目录下, 用于将前端的请求url转发到views函数中。
  • views.py, 位于/data/ItcastBrain/Yxb/目录下, 用于接收来自前端请求的数据, 并传给api.py中的函数执行, 并获得结果, 封装成响应体返回。
  • api.py, 位于/data/ItcastBrain/Yxb/目录下, 用于执行主要的逻辑处理部分, 并返回结果给views.py中的函数。

  • 编写urls.py:


from django.conf.urls import url
from django.contrib import admin
# 引入Yxb中的views.py
from Yxb import views as y_views

urlpatterns = [
url(r’^admin/‘, admin.site.urls),
url(r’^api/v1/get_trans[/]?$’, y_views.get_trans),
]


  • 代码位置:
    • /data/ItcastBrain/api/urls.py

  • 编写views.py


from django.http import (
HttpResponse,
StreamingHttpResponse,
FileResponse,
)
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework.authentication import (
SessionAuthentication,
BasicAuthentication,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import authentication_classes
from rest_framework.decorators import permission_classes
import json
import re
import os
from . import api
import time

@api_view([“POST”])
def get_trans(request):
“””主要处理逻辑
Args:
request: 请求体

  1. Return:<br /> 最后输出的json格式的结果,{"score": "2.5", "confidence": "M"}<br /> `score`: 预估的得分;<br /> `confidence`: M表示中等置信度;<br /> 更多的信息请参考接口文档.<br /> """
  2. # 获得请求数据<br /> content = json.loads(request.body.decode())<br /> pid = content["pid"]<br /> student_answer = content["student_answer"]<br /> true_answer = content["true_answer"]<br /> score = content["full_marks"]<br /> # 进行数据预处理<br /> result = api.preprocessor(<br /> student_answer, true_answer<br /> )
  3. # 所有使用规则预测的标签,置信度confidence都是高级别的"H"<br /> if result == "Y":<br /> res = {<br /> "status": "0",<br /> "pid": pid,<br /> "student_answer": student_answer,<br /> "true_answer": true_answer,<br /> "score": score,<br /> "confidence": "H",<br /> }<br /> elif result == "N":<br /> res = {<br /> "status": "0",<br /> "pid": pid,<br /> "student_answer": student_answer,<br /> "true_answer": true_answer,<br /> "score": "0",<br /> "confidence": "H"<br /> }<br /> else:<br /> # 进行模型预测<br /> # 所有使用模型预测得到的标签['Y', 'N']置信度都是中等级别"M"<br /> # 模型预测得到'M'则置信度为低级别"L"<br /> # 如果返回'P'标签,说明模型服务出现问题,则状态status为"1".<br /> model_result = api.apply_model(<br /> result[0], result[1]<br /> )<br /> if model_result == "Y":<br /> res = {<br /> "status": "0",<br /> "pid" : pid,<br /> "student_answer": student_answer,<br /> "true_answer": true_answer,<br /> "score": score,<br /> "confidence": "M",<br /> }<br /> elif model_result == "N":<br /> res = {<br /> "status": "0",<br /> "pid" : pid,<br /> "student_answer": student_answer,<br /> "true_answer": true_answer,<br /> "score": "0",<br /> "confidence": "M",<br /> }<br /> elif model_result == "M":<br /> res = {<br /> "status": "0",<br /> "pid" : pid,<br /> "student_answer": student_answer,<br /> "true_answer": true_answer,<br /> "score": str(int(score) // 2),<br /> "confidence": "L",<br /> }<br /> else:<br /> res = {"status": "1"}<br /> return Response(res)

  • 代码位置:
    • /data/ItcastBrain/Yxb/views.py

  • 修改api.py:
    • 即在views.py中调用的一些接口,比如数据预处理,模型预测等,这些API其实我们在之前训练过程中都已经实现过,这里会做一些微小的变动。


# 导入必备的工具包
import re
import requests

数据清洗后的需要满足的最大长度限制
MAX_LENGTH_LIMIT = 100

多项填空时的各个答案的分割标志
SPLIT_MARK = “[PAD]”

def _etl(string):
# 去除任意空白符并进行小写
string = re.sub(“\s+|;|;|【|】|“|”|’|‘“, “”, string).lower()
# 找到异常的html并进行去除
m = re.findall(“<.*?>”, string)
for mm in m + [“&lt”, “&gt”, “&nbsp”, “&rdquo”, “&quot”, “&amp”, “&ldquo”]:
string = string.replace(mm, “”)
return string

def preprocessor(student_answer:list, true_answer:list):
“””输入的过滤处理函数, 并在满足一定要求时,直接返回结果
Args:
student_answer: 学生的答案列表.
true_answer: 真实的答案列表.

  1. Return:<br /> 当满足一定条件时直接返回标签,["Y", "N"]中的一个,且置信等级为"H"<br /> 若不满足一定条件,则返回经过数据清洗的字符串数据,等待模型预测.<br /> """<br /> # 首先确定学生是否作答或答案是否为空,这两种情况都直接输出零分标签"N"<br /> # 这个输出结果需要和业务同步<br /> if not student_answer or not true_answer: return "N"<br /> if not student_answer[0] or not true_answer[0]: return "N"<br /> # 判断学生答案与真实答案是否相同,相同则直接返回"Y"<br /> if student_answer == true_answer: return "Y"
  2. # 接下来对输入的答案进行清洗, 过程与之前的清洗相同<br /> # result用于存储清洗后的答案对,分别为student_answer,true_answe<br /> result = []<br /> for answer in [student_answer, true_answer]:<br /> try:<br /> res = []<br /> # 对于多项填空需要进行循环<br /> for string in answer:<br /> string = _etl(string)<br /> res.append(string)<br /> # 并且每个填空内容用[PAD]分割<br /> result.append(SPLIT_MARK.join(res))<br /> except Exception as e:<br /> print("Warning:" + str(e))<br /> result.append("")
  3. # 对清洗后的答案我们仍然有一些规则判断<br /> if not student_answer or not true_answer: return "N"<br /> # 如果一致,仍然为"Y"<br /> student_answer = result[0]<br /> true_answer = result[1]<br /> if student_answer == true_answer: return "Y"<br /> # 如果长度大于清洗后的长度限制,仍然需要截断,因为模型没有大于该长度的语料<br /> if len(student_answer) >= MAX_LENGTH_LIMIT:<br /> student_answer = student_answer[:MAX_LENGTH_LIMIT]<br /> if len(true_answer) >= MAX_LENGTH_LIMIT:<br /> true_answer = true_answer[:MAX_LENGTH_LIMIT]
  4. # 通过badcase分析,我们还需要加入数值类型规则:<br /> # 当两者的都是数值类型时,如果不同则为"N", 如果相同则为"Y".<br /> try:<br /> # 如: 对"1.65"的表示使用eval变成1.65, 再进行对比<br /> if eval(student_answer) == eval(true_answer):<br /> return "Y"<br /> else:<br /> return "N"<br /> except Exception as e:<br /> pass
  5. return student_answer, true_answer

MODEL_SERVE_URL = “http://0.0.0.0:5004/v1/model_prediction/

def apply_model(student_answer:str, true_answer:str):
“””向模型发送标签预测请求
Args:
student_answer: 学生答案.
true_answer: 真实答案.

  1. Return:<br /> 最终模型预测的标签.<br /> """<br /> data = {"student_answer": student_answer, "true_answer": true_answer}<br /> try:<br /> res = requests.post(url=MODEL_SERVE_URL, json=data, timeout=200)<br /> label = res.text<br /> except Exception as e:<br /> print(e + "| 模型请求出错, 使用`P`标签!")<br /> label = "P"<br /> print("label:", label)<br /> return label

  • 代码位置:
    • /data/ItcastBrain/Yxb/api.py

  • 第三步: 安装supervisor监控守护工具并启动服务
    • Supervisor服务监控:
      • Supervisor是用Python开发的一个client/server服务,是Linux/Unix系统下的一个进程管理工具。它可以很方便的监听、启动、停止、重启一个或多个进程, 并守护这些进程。
      • 在项目中, Supervisor用于监控和守护AI整体服务和模型服务.

  • 安装并启动supervisor:


# 使用yum安装supervisor
yum install supervisor -y
基本使用方法:
# 编辑配置文件, 指明监控和守护的进程开启命令,
# 请查看/data/ItcastBrain/supervisord.conf文件
# 开启supervisor, -c用于指定配置文件
supervisord -c /data/ItcastBrain/supervisord.conf



# 查看监控的进程状态:
supervisorctl status

bert_server1 RUNNING pid 23836, uptime 8 days, 1:02:59
bert_server2 RUNNING pid 23893, uptime 8 days, 1:02:57
main_server RUNNING pid 8018, uptime 0:07:54
nginx RUNNING pid 23911, uptime 8 days, 1:02:57



# 关闭supervisor
supervisorctl shutdown


  • 关于supervisor.conf的简单分析:


...

; 主服务配置命令,使用/data/anaconda3/bin/下的uwsgi命令,
; 指向/data/ItcastBrain/conf/uwsgi.ini配置
; 这些配置文件已经为同学们准备就绪
[program:main_server]
command=/data/anaconda3/bin/uwsgi —ini /data/ItcastBrain/conf/uwsgi.ini —close-on-exec ; the program (relative uses PATH, can take args)
;process_name=%(program_name)s ; process_name expr (default %(program_name)s)
;numprocs=1 ; number of processes copies to start (def 1)
;directory=/tmp ; directory to cwd to before exec (def no cwd)
;umask=022 ; umask for process (default None)
;priority=999 ; the relative start priority (default 999)
;autostart=true ; start at supervisord start (default: true)
;startsecs=1 ; # of secs prog must stay up to be running (def. 1)
;startretries=3 ; max # of serial start failures when starting (default 3)
;autorestart=unexpected ; when to restart if exited after running (def: unexpected)
;exitcodes=0,2 ; ‘expected’ exit codes used with autorestart (default 0,2)
stopsignal=QUIT ; signal used to kill process (default TERM)
;stopwaitsecs=10 ; max num secs to wait b4 SIGKILL (default 10)
stopasgroup=false ; send stop signal to the UNIX process group (default false)
killasgroup=false ; SIGKILL the UNIX process group (def false)
;user=chrism ; setuid to this UNIX account to run the program
;redirect_stderr=true ; redirect proc stderr to stdout (default false)
stdout_logfile=/data/ItcastBrain/log/main_server_out.log ; stdout log path, NONE for none; default AUTO
stdout_logfile_maxbytes=1MB ; max # logfile bytes b4 rotation (default 50MB)
;stdout_logfile_backups=10 ; # of stdout logfile backups (0 means none, default 10)
;stdout_capture_maxbytes=1MB ; number of bytes in ‘capturemode’ (default 0)
;stdout_events_enabled=false ; emit events on stdout writes (default false)
stderr_logfile=/data/ItcastBrain/log/main_server_err.log ; stderr log path, NONE for none; default AUTO
stderr_logfile_maxbytes=1MB ; max # logfile bytes b4 rotation (default 50MB)
;stderr_logfile_backups=10 ; # of stderr logfile backups (0 means none, default 10)
;stderr_capture_maxbytes=1MB ; number of bytes in ‘capturemode’ (default 0)
;stderr_events_enabled=false ; emit events on stderr writes (default false)
;environment=A=”1”,B=”2” ; process environment additions (def no adds)
;serverurl=AUTO ; override serverurl computation (childutils)

; 监控代理模型服务的nginx,配置文件指向/data/ItcastBrain/conf/nginx/nginx.conf
[program:nginx]
command=/usr/sbin/nginx -c /data/ItcastBrain/conf/nginx/nginx.conf -g “daemon off;”

; 下面是日志写入位置和最大限制
stdout_logfile=/data/ItcastBrain/log/nginx_out.log
stderr_logfile=/data/ItcastBrain/log/nginx_err.log
stdout_logfile_maxbytes=1MB
stderr_logfile_maxbytes=1MB

; 模型服务
[program:bert_server1]
command=gunicorn -w 4 -b 0.0.0.0:5003 —chdir /data/ItcastBrain/Yxb/bert_server/ app:app
stdout_logfile=/data/ItcastBrain/log/bert1_out.log
stderr_logfile=/data/ItcastBrain/log/bert1_err.log
stdout_logfile_maxbytes=1MB
stderr_logfile_maxbytes=1MB

; 备用模型服务
[program:bert_server2]
command=gunicorn -w 4 -b 0.0.0.0:5004 —chdir /data/ItcastBrain/Yxb/bert_server/ app:app
stdout_logfile=/data/ItcastBrain/log/bert1_out.log
stderr_logfile=/data/ItcastBrain/log/bert2_err.log
stdout_logfile_maxbytes=1MB


接下来,我们还需要最后一步,向Django指明新增的应用Yxb,即修改/data/ItcastBrain/server/settings.py文件:

52 INSTALLED_APPS = [
53 ‘django.contrib.admin’,
54 ‘django.contrib.auth’,
55 ‘django.contrib.contenttypes’,
56 ‘django.contrib.sessions’,
57 ‘django.contrib.messages’,
58 ‘django.contrib.staticfiles’,
59 ‘api’,
60 ‘django_celery_beat’,
61 ‘rest_framework’,
62 ‘corsheaders’, # pip install django-cors-headers
63 ‘Info’, # 上一个信息中心的应用Info,默认给大家添加
64 ‘Pm’, # 本次需要添加的应用,也就是Pm文件夹名称
65 ]


全部准备就绪,我们需要使用supervisor重启服务(每次修改代码都需要重新启动服务)

# 在/data/ItcastBrain/目录下运行
supervisord -c supervisord.conf

如果你需要查看报错日志,可以通过/data/ItcastBrain/log中的main_server_err.log查看


假设已经正常启动服务,下面我们将编写一个测试脚本进行测试.

  • 测试脚本:


import requests

主服务请求地址
url = “http://0.0.0.0:8087/api/v1/get_trans/

data = {
“pid”: “12312”,
“student_answer”: [“alt”],
“true_answer”: [“title”],
“full_marks”: “2”,
}

多层嵌套必须使用json
res = requests.post(url, json=data)
print(res.text)


  • 输出效果:


{
“status”:”0”,
“pid”:”12312”,
“student_answer”:[“alt”],
“true_answer”:[“title”],
“score”:”0”,
“confidence”:”M”
}


  • 当然,对于使用方来讲,他们在测试过程中会使用更大量的数据进行测试,以确保所有代码能够运行成功。最后我们将给对方一个API文档作为最终交付物,内容详见 附件《考试中心填空题API说明》

小节总结

  • 整体解决方案的实施步骤
    • 第一步: 获取指定数据并进行数据清洗和分析
    • 第二步: 根据分析结果进行模型选择与训练代码实现
    • 第三步: 整体服务部署与联调测试

4.4 针对数据监控部门的反馈调整

学习目标

  • 对数据监控部门反馈问题的分析。
  • 了解针对反馈所做的一些策略调整。

数据监控部门的反馈

  • 不同部门对于系统的监控和反馈并不相同。在考试中心,研发工程师使用A/B评测的方式来判断模型的作用,核心指标是平均准确率和WRT(每周批阅AR测试的总耗时)以及ADR(准确率衰减率,代表机器准确率随着时间的变化程度),以下是三周A/B testing的结果: | | ACC | WRT | ADR | | —- | —- | —- | —- | | Model | 85% | 18m | -3% | | 人工 | 80% | 1532m | 恒定 |

  • 结论:

    • 可以看出,我们的模型在上线后,实际的准确率优于教师批阅,并为人工节省大量时间。但是ADR的衰减是显著的,在技术领域,知识的迭代速度很快,因此出题人所出的题目也越来越多样,已有模型很难作出更多的泛化,因此搭建模型训练反馈系统,以及优化模型的推断和训练速度都是需要开展的工作。

后期对模型进行的优化

  • 1,对模型进行量化压缩
  • 2,使用ONNX-Runtime进行推断加速
  • 3,使用混合精度训练提升训练速度

1,对模型进行量化压缩

  • 模型量化技术是起步最早且最成熟的模型压缩方法,下面我们将介绍有关量化的知识:7.1 模型量化技术

  • 本项目中对模型进行量化的过程,与7.2 模型量化技术中的示例基本上如出一辙,大家可以自己进行相应的尝试。这里给出重要的对比指标。 | CPU设备 | 准确率 | 推断时间 | 模型大小 | | —- | —- | —- | —- | | 非量化模型 | 94.0% | 45ms | 711M | | int8量化模型 | 88.5% | 20ms | 454M |

  • 结论:
    • 通过实验可以看出,动态量化能够有效的加快推理速度,但是同时模型的评估指标也受到了较大影响,因此在对推理速度没有严苛要求的场景下,需要谨慎考虑。

2,使用ONNX-Runtime进行推断加速


  • 首先将我们训练的模型转成onnx格式:
    • huggingface团队为我们提供了转换脚本:convert_graph_to_onnx.py
    • 该脚本的使用对pytorch以及transformer有严苛要求: torch==1.6.0transformers==3.1.0
    • 需要我们使用以上版本重新训练模型,再进行onnx转换(当前版本的新功能)。

  • 进行转换:


# 当前模型路径
MODEL_DIR=”./bert_multi_finetuning_test/“

输出onnx模型的路径(带模型名字)
OUTPUT_DIR=”./onnx_bert_multi_finetuning_test/pytorch.onnx”

python3 convert_graph_to_onnx.py —pipeline sentiment-analysis —framework pt —model $MODEL_DIR $OUTPUT_DIR

  • 输出效果:
    • 在./onnx_bert_multi_finetuning_test/路径下出现pytorch.onnx。

  • onnx模型使用:


import numpy as np
# 从transformers中导入BERT模型的相关工具
from transformers import BertForSequenceClassification, BertTokenizerFast
from onnxruntime import InferenceSession, ExecutionMode, SessionOptions

还原数值映射器
tokenizer = BertTokenizerFast.from_pretrained(“bert-base-multilingual-cased”)

tokens = tokenizer.encode_plus(“alter”, “alter”)
# 转成numpy形式
tokens = {name: np.atleast_2d(value) for name, value in tokens.items()}

使用模型创建onnxruntime的session
onnx_model_name = “./onnx_bert_multi_finetuning_test/pytorch.onnx”
options = SessionOptions()
options.intra_op_num_threads = 1
options.execution_mode = ExecutionMode.ORT_SEQUENTIAL
ort_session = InferenceSession(onnx_model_name)
ort_outs, pooled = ort_session.run(None, tokens)
print(ort_outs)

  • 最终效果对比: | CPU设备 | 准确率 | 推断时间 | | —- | —- | —- | | bin模型 | 94.0% | 45ms | | onnx模型 | 92.2% | 31ms |

  • 结论:

    • ONNX-Runtime进行加速虽没有动态量化的推断时间短,但是他却很好保证了模型准确率,可以作为重要的推断加速方法。

3,对比混合精度训练(AMP)

  • 什么是混合精度训练:
    • 在主流框架的训练过程中,一般都使用fp32(单精度)存储参数,而为了加速模型训练,我们将部分fp32变成fp16以简化计算。
    • 模型量化是发生在推断阶段,为了优化推断时间。而混合精度训练是发生在训练阶段,为了优化训练时间。
    • huggingface提供的微调脚本中已经为我们提供有关混合精度训练的参数,即fp16和fp16_opt_level

  • 在gpu上进行混合精度训练首先需要下载nvidia对应的工具apex


git clone [https://github.com/nvidia/apex.git/](https://github.com/nvidia/apex.git/)
cd apex
pip install -v —no-cache-dir —global-option=”—cpp_ext” —global-option=”—cuda_ext” ./

安装apex还需要系统当前的cuda版本和pytorch的版本匹配(实质上是匹配编译pytorch版本的cuda)
# 例如:torch==1.6.0(该版本使用cuda-11.0编译),因此系统的cuda版本必须是11.0
# 我们这里使用torch==1.3.0(该版本使用cuda-10.1编译)
# 通过 export CUDA_HOME=/usr/local/cuda-10.1来指定版本


  • 加入fp16和fp16_opt_level
    • 其中fp16_opt_level 可选级别为[‘O0’, ‘O1’, ‘O2’, ‘O3’],默认’O1’
    • ‘O0’代表不进行混合精度,只使用fp32,作为基准。
    • ‘O1’代表混合精度,为了保持不损失过多精度,将近一半的层仍然使用fp32。
    • ‘O2’代表混合精度,除了BN层参数都使用fp16。
    • ‘O3’代表全部使用fp16。


python3 run_glue.py \
—model_type BERT \
—model_name_or_path bert-base-multilingual-cased \
—task_name MNLI-MM \
—do_train \
—do_eval \
—data_dir $DATA_DIR/ \
—max_seq_length 100 \
—learning_rate 2e-5 \
—num_train_epochs 1 \
—save_steps 2000 \
—logging_steps 2000 \
—overwrite_output_dir \
—output_dir $SAVE_DIR \
—fp16 \
—fp16_opt_level O1


  • 效果对比:


WARNING - __main__ - Process rank: -1, device: cuda, n_gpu: 1, distributed training: False, 16-bits training: True

Tesla T4 准确率 训练时间
O0混合精度模型 94.0% 36m03s
O1混合精度模型 85.5% 28m40s
O2混合精度模型 78.8% 23m12s
O3混合精度模型 43.6% 2m17s

  • 结论:
    • 通过实验可以看出,混合精度训练能够显著加速训练速度(推断速度几乎无变化),但是同时模型的评估指标也受到了不同程度的影响,我们会在之后的特定场景中使用该方法。

小节总结

  • 学习了整体解决方案的实施步骤:
    • 第一步: 获取指定数据并进行数据清洗和分析
    • 第二步: 根据分析结果进行模型选择与训练代码实现
    • 第三步: 整体服务部署与联调测试

  • 在第一步数据分析中我们实现了:
    • 预览分析与数据清洗
    • 统计文本长度分布

  • 在第二步我们学习了根据分析结果进行模型选择与训练代码实现:
    • 第一步: 训练集与验证集的划分
    • 第二步: 根据现有任务选择标准任务类型
    • 第三步: 使用微调脚本进行训练和验证
    • 第四步: Badcase分析以便对数据进行调整和重新训练

  • 在第三步我们学习了整体服务部署与联调测试的具体步骤:
    • 第一步: 拷贝服务框架的基本文件
    • 第二步: 编写三个核心文件中的代码内容
    • 第三步: 安装supervisor监控守护工具并启动服务
    • 第四步: 进行联调测试

  • 根据数据部门的反馈对模型进行了优化
    • 1,对模型进行量化压缩
    • 2,使用ONNX-Runtime进行推断加速
    • 3,使用混合精度训练提升训练速度