基于自然语言处理和意图识别的智能交互助手,能够理解和响应用户的页面交互指令,并执行相应的操作
Intelligent Interaction Assistant(IIA)
源码仓库:https://github.com/GSemir0418/IIA-algo
BERT 预训练模型下载:https://huggingface.co/google-bert/bert-base-chinese/tree/main
仅需
README.md
、config.json
、pytorch_model_bin
、tokenizer.json
、vocab.txt
意图识别(Intent Recognition)是自然语言处理(NLP)中的一个重要任务,它旨在确定用户输入的语句中所表达的意图或目的。简单来说,意图识别就是对用户的输入进行语义理解,以便更准确地回答用户的问题或提供相关的服务。
在 NLP 中,意图识别通常被视为一个分类问题,即通过将输入语句分类到预定义的意图类别中来识别其意图。这些类别可以是各种不同的任务、查询、请求等,例如查询、修改、删除、新增等。
下面以一个简单的例子来说明意图识别的概念:
用户输入: "帮我查下 id 为 4001 的全部数据” 意图识别:查询数据,id 字段为 4001
在这个例子中,将用户的意图为 Query
,槽位是 id: 4001
本项目面向公司调度排产软件,实现了一个基于 BERT 的意图(intent)和槽位(slots)联合识别模块,能够理解用户对工单数据页面的中文自然语言基本交互(CRUD)指令,识别指令意图,提供相关的服务并做出正确响应。
利用
[CLS]
token 对应的 last hidden state 去预测整句话的 intent,并利用句子 tokens 的 last hidden states 做序列标注;找出包含 slot values 的 tokens。
BERT(Bidirectional Encoder Representations from Transformers)是一个由Google开发的预训练语言模型,核心优势在于其深度双向表示、高效的Transformer结构,以及强大的预训练策略,这些特点共同使得它在多项 NLP 任务中取得了前所未有的成果
OpenAI 的大模型,例如 GPT-3,是基于大规模的非监督学习构建的,它的主要优势在于生成连贯和多样化的文本。例如,如果你要编写一个故事、生成一篇文章或创造一段对话,GPT-3 能根据提供的提示自由地“即兴创作”,在语法结构和句子流畅性方面做得非常好。
相比之下,BERT(双向编码器表示)模型是设计来更好地理解单词在句子中的双向上下文,特别是在理解特定句子或文本段落的精确含义方面表现突出。它适用于需要深层次语言理解的任务,如文本分类、命名实体识别、问题回答和语言推断任务。
OpenAI 的 GPT-3 更像是一个擅长广泛应用的语言生成器,能够创建各种各样的文本内容;而 BERT 更像是一个专精的语言理解专家,在特定语义理解任务上表现更为出色。
- 双向上下文理解:与早期模型不同(例如 LSTM,它们通常单向处理文本),BERT 利用 Transformer 模型对整个输入文本进行编码,以便为每个单词捕获包含左右两侧上下文的表示。这种双向特性允许BERT更准确地理解词义和语法结构。
Transformer 模型是一种基于注意力机制的架构,它主要为了解决序列到序列的任务,如机器翻译。它的设计允许计算机一次性处理整个文本序列,而非像传统的递归神经网络(RNN)和长短期记忆网络(LSTM)一样,逐个词汇地处理。这种处理方式增加了计算效率,并允许模型捕捉更长距离的依赖关系。
Transformer 完全依靠注意力机制,排除了卷积和循环的结构。这使得模型特别适合并行化处理,也就是在多个处理单元上同时处理数据,从而加速学习过程。
注意力机制类似于我们做阅读理解时的聚焦能力,它帮助Transformer模型有效地关注文本中最相关的部分来更好地进行翻译、总结或回答问题,就像我们在海量信息中找到关键点一样。
- 大规模预训练和微调:BERT的模型参数是通过在大型文本语料库上进行预训练得到的,这个预训练过程涉及两个任务:掩码语言建模(Masked Language Model)和下一句预测(Next Sentence Prediction)。在预训练完成之后,BERT 能够通过微调(fine-tuning)过程被轻易地适配到各种下游 NLP 任务,例如情感分析、问答系统、实体识别等。
- Transformer架构:BERT 依赖于 Transformer 模型,它是一种注意力机制(attention mechanism),使得模型能够关注输入序列中的不同部分,特别是那些对理解当前单词最有影响的区域。Transformer 模型取消了循环神经网络(RNN)的顺序处理机制,允许模型并行处理序列中的每个元素,从而提高了计算效率。
- 微调的灵活性:与固定的特征提取器不同,BERT 经过微调后能够根据具体任务调整其内部表示,确保模型的输出适合指定的应用。微调是通过在预训练模型的基础上增加额外的输出层,并在特定任务的数据集上进行有监督学习来完成的。
- Transfer Learning:BERT 是转移学习(transfer learning)的一个典范,它的预训练-微调范式使其能够将大量未标记数据中学到的知识转移到标记数据较少的任务上,这有助于提升小数据集上的任务性能。
- Python 3.11.0
模型的训练主要依赖于三方面的数据:
- 训练数据:训练数据以
json
格式给出,每条数据包括三个关键词:text
表示待检测的文本,intent
代表文本的类别标签,slots
是文本中包括的所有槽位以及对应的槽值,以字典形式给出。样例如下:
{
"text": "找出id为123的记录",
"intent": "QUERY",
"slots": {
"id": "123"
}
},
- 意图标签:以
txt
格式给出,每行一个意图,未识别意图以[UNK]
标签表示。样例如下:
[UNK]
QUERY
UPDATE
...
- 槽位标签:与意图标签类似,以
txt
格式给出。包括三个特殊标签:[PAD]
表示输入序列中的 padding token,[UNK]
表示未识别序列标签,[O]
表示没有槽位的 token 标签。对于有含义的槽位标签,又分为以B
开头的槽位开始的标签,以及以I_
开头的其余槽位标记两种。
[PAD]
[UNK]
[O]
I_id
B_id
...
以查询场景为例,本项目初步实现查询 id 字段的自然语言意图识别
以下为本项目准备与优化数据集保证的原则:
- 数据多样性:即使是同样的意图(如“查找id”)也应包含不同的表达方式、不同的字词排序和结构
- 不同的槽位值:为了使模型能够处理各种不同的输入,为每个槽位都应提供多种不同的值。例如,不仅仅是数字,还可能是字母数字组合的ID
- 不同的槽位类型:除了 id 字段,考虑是否可能存在其他类型的槽位,比如产量、名称、状态或者自定义的分类
- 负样本:包括一些不相关的语句或错位意图的例子。这样做可以帮助模型学会辨别不属于正常意图的句子
- 噪音数据:包括一些带有文本错误或语法不正确的句子,以便模型能够处理现实世界中不完美的输入
- 数据平衡:确保数据在不同槽位、意图和表达方式之间都是平衡的。不要让某个特定的槽位或表达方式过多,从而造成模型偏差
- 数据扩增:如果您觉得手动编写例句太麻烦,可以利用数据扩增工具来自动生成新的训练数据
- 精确的槽位边界:确保为槽位值定义的边界是精确的,不要包含任何无关的词语或符号
- 数据标注一致性:例如,如果使用
id
来表示某种标识,在所有样本中都坚持使用同一概念
从 JSON 数据集文件中提取信息,然后创建两个文本文件,一个是保存意图标签的 intent_labels.txt
,另一个是保存槽位标签的 slot_labels.txt
采用增量学习(incremental learning)或分阶段培训。这种方法通过逐步引入不同的概念和复杂性来训练模型,可以帮助模型更集中地学习特定的模式,并在训练初期减少混淆;同时确保在增加新知识和能力的同时,不会损失原有的准确性。此外,这种方式可以帮助定位问题,即如果引入新的数据导致性能下降,可以更容易地识别问题所在并进行相应的修正。
继承自 BertMultiHeadJointClassification 多任务联合识别模型,针对本项目的实际应用场景进行如下简化:
- 任务简化:只需要单一的意图和槽位标签数目,因此它只创建了一个意图识别头和一个槽位填充头,专注于单一任务集而不是多任务学习
意图识别分类头负责判断一个句子整体要表达的目的是什么。就像你跟助手说:“我饿了”,它的意图识别头就要能明白你的意图是“想吃东西”。
工作原理:它会看到你的句子,并从中提取关键的信息,然后对比它所学习过的各种可能的意图,判断出最接近的那一个。就好比是一个经验丰富的侦探,通过分析线索来确定你的真正目的。
该分类器的每一个线性层都被用来预测句子的整体意图或分类。
槽位填充分类头则是用来识别和理解句子中的具体信息片段,它关注句子的细节。如果你说:“帮我订明天上午的飞往纽约的票”,这只眼睛就要弄清楚“明天上午”是时间信息,“飞往纽约”是目的地信息。
槽位填充分类头的原理是,将句子中的每一个词或词语分类,归入不同的“槽位”,它们代表了不同的信息类别,就像填写表格时把信息放在正确的栏目中一样。
标记头中的每个线性层用于为输入句子中的每个单词或标记预测一个分类标签,这些标签代表了单词在句中的具体角色或属性。每个单词都会被分配一个标签,所以模型要为每个单词单独进行预测,使用的是词级别的特征(例如BERT模型产出的sequence_output)
- 参数简化:简化为只接受一个意图标签和一个槽位标签,对应于单个的意图和槽位识别任务
- 逻辑简化:无需处理 output_attentions、output_hidden_states、return_dict 参数
output_attentions, output_hidden_states 常用于模型分析(如可视化注意力权重)或者高级应用(如特征抽取),但在标准的训练和推理任务中不常用
return_dict 参数用于控制输出格式,但如果默认只需处理损失、意图和槽位的识别结果,这个参数也就不必要了
- 准备
- 加载分词器
- 读取数据集,读取训练和验证数据,并将数据封装成适用于训练的格式
- 设置 cuda 环境变量,判断使用CPU还是GPU训练
- 初始化 BERT 预训练模型
- 准备数据加载器,将数据打乱并批量载入模型
- 计算训练的总步数,准备优化器和学习率调度器
优化器和学习率调度器在神经网络训练中扮演着调整模型参数的角色,以帮助模型更好地学习和提升它的性能。
优化器:
想象一下,你在山区,目标是找到最低点的位置,也就是山谷。在训练神经网络的情况下,这个山的每一点都对应着一组模型参数的值,山的高度代表错误的大小(损失)。优化器就好比一个向导,帮助你决定在山间行进的步骤:往哪个方向走,走多远(步长)。
- 方向:优化器决定根据当前位置(当前参数值)和你眼前的斜坡(损失函数梯度)来找下一步应该往哪走。
- 步长:它还决定你得走多远(更新参数值的幅度)。步长太大可能越过山谷,太小可能到不了山谷或走得太慢。
在神经网络训练过程中,优化器的任务是更新神经网络的权重,逐步减少错误(即损失)。
学习率调度器:
如果优化器是确定方向和步长的向导,那么学习率调度器就是向导手中的地图,它告诉向导在整个旅程中应该如何调整步长(学习率)。它是一个根据预设策略逐渐调整学习率的方案。
- 预热:旅程刚开始时,你可能不确定怎样的斜度最适合往下走,所以先以较小的步子开始,随着对斜坡的适应,逐渐增大步子。
- 调整策略:旅程中,如果你发现现在的步子太大可能会让你跌倒,或者太小以至于走不动,你可能需要适当调整你的步子。调度器就是帮助你在整个训练过程中,根据当前情况适时调整步长的计划。
简言之,优化器是决定如何更新模型参数的规则,而学习率调度器是告诉优化器随着时间改变步长大小的策略。两者合作,希望带领模型走向高效准确的学习之路。
-
训练
- 开始训练循环 (epoch 是训练的轮次)
- 初始化总损失: 在开始新的轮次之前,把总损失设置为0,后面会用于计算这个轮次的平均损失
- 设置模型为训练模式
- 遍历数据加载器中的批次:从批次中提取出输入的ID、意图标签和槽位标签
- 模型推理和计算损失: 将数据送入模型进行处理,并返回结果。模型输出一个字典,其中包含了损失,即当前模型的表现
- 累加损失值
梯度在机器学习中是一个非常重要的概念,表示某一函数在各个方向上的变化率或斜率的向量。在神经网络中,梯度是损失函数(我们使用的表现衡量标准)相对于模型参数的偏导数。简而言之,梯度告诉我们如何改变参数以使模型表现得更好(即减少损失)。
想象你在爬山,你的目标是到达山顶。你站在某个地方,想知道往哪个方向迈步可以更直接地朝山顶前进。这里的“方向”和“迈步的决定”类似于梯度的概念。在训练神经网络时,梯度指的是如果我们想要模型的错误变小(即找到损失函数的“山底”),应该如何调整模型的参数(即你的“位置”)。
- 处理梯度累积: 如果你设置梯度累积步骤大于1,则在执行反向传播之前,先将损失除以累积步骤数
梯度累积是一种用于训练大型模型或使用大型批量大小时的技巧。因为这些情况可能会导致内存不足,所以通过梯度累积可以实现更有效的内存利用。它通过在多个小批次(minibatches)上累积(而不是立刻应用)计算出的梯度,然后统一更新模型权重。这样,即使我们只能用较小的批次来计算梯度,最终也能模仿较大批次的优化效果。
如果你背着一个很重的背包爬山(相当于训练一个大型神经网络),可能一次迈很大的步子会很吃力。梯度累积就像是我们决定小步前进,但是不立刻决定下一步往哪里走。相反,我们想待走了几步之后,再根据这几步来决定一个较大的步伐方向。在训练过程中,这样做可以帮助我们即使在有限的资源下(如内存限制),也能模拟一次性迈出大步的效果。
- 损失反向传播: 反向传播过程中,计算损失关于模型参数的梯度
反向传播是训练神经网络时用来计算梯度的一种算法。在前向传播过程中,输入数据通过网络流动,并生成预测输出及其对应的损失值。然后在反向传播过程中,损失函数相对于网络参数的梯度被计算出来并传播回网络,这样可以告诉我们如何调整每个参数以减少损失。反向传播是现代神经网络训练中最核心的部分。通过反向传播,我们可以更新模型的权重,使模型在预测任务上表现更好。
损失反向传播就像是在山上安装了一套高级通讯设备。当你脚下的地形变化时(即模型对数据的预测和实际情况有差距),这套设备会帮助你通信并获得从山顶(输出层)到你当前位置(输入层)每一步的准确反馈。这样,你就可以精确地知道在每一段山路上如何调整步伐大小和方向,从而更有效地登顶(即优化模型)。
- 梯度裁剪和更新参数: 检查是否到了更新梯度的步骤。裁剪梯度可以防止在神经网络训练过程中出现梯度爆炸的问题,导致不稳定的情况。调用优化器和学习率调度器来更新参数,并重置梯度
梯度爆炸是训练中可能遇到的问题,当梯度的值变得非常大,以至于导致数值计算上的问题(如溢出),结果会使模型的参数更新步骤变得非常极端,从而使得模型无法收敛到好的解。这通常发生在深层网络中,当梯度通过多个层传播时,可能因为连乘效应而增长得很快。
现在,想象你正在爬的山突然变得非常陡峭,每迈出一步都比上一步大得多。如果不控制的话,你可能一不小心就会从山上跌落。在神经网络中,这种“每步越来越大”就是梯度爆炸。如果梯度变得非常大,它会导致模型在训练中更新参数时出现极端的变化,从而引起训练过程的失控。
- 计算这个轮次的平均损失: 把总损失除以批次数量来获取这个轮次的平均损失
- 评估模型在验证集上的性能: 使用之前定义的 dev 函数来计算模型在验证集上的准确率
- 保存性能最佳的模型
- 训练结束后的最后评估: 在所有训练轮次结束后,再次评估模型在验证集上的表现,输出最终模型的性能和保存位置
主要参数如下:
param | value | notes |
---|---|---|
max_training_steps | 0 | |
batch_size | 32 | |
train_epochs | 20 | |
gradient_accumulation_steps | 1 | |
learning_rate | 5e-5 | |
adam_epsilon | 1e-8 | |
warmup_steps | 0 | |
weight_decay | 0.0 | |
max_grad_norm | 1.0 | |
训练结束后,我们通过在 JointIntentSlotDetector
类中加载训练好的模型进行意图与槽位预测。
使用 JointIntentSlotDetector 加载模型
TODO JointIntentSlotDetector 学习
python predict.py
from detector import JointIntentSlotDetector
import time
start1_time = time.perf_counter()
model = JointIntentSlotDetector.from_pretrained(
# model_path='./save_model/bert-base-chinese',
# tokenizer_path='./save_model/bert-base-chinese',
# intent_label_path='./data/SMP2019/intent_labels.txt',
# slot_label_path='./data/SMP2019/slot_labels.txt'
model_path='./save_model/my-model',
tokenizer_path='./save_model/my-model',
intent_label_path='./data/op-data/intent_labels.txt',
slot_label_path='./data/op-data/slot_labels.txt'
)
start2_time = time.perf_counter()
all_text = ['帮我查下id为4001的全部数据', '查ID是2的数据', '给我id为1001BC2的全部数据', '查ID4102', '急需看一下ID为345的那个记录是啥']
for i in all_text:
print(model.detect(i))
end_time = time.perf_counter()
time1 = (end_time - start1_time) / 3600
time2 = (end_time - start2_time) / 3600
print("所有检测时间(包括加载模型):", time1, "s", "除去模型加载时间:", time2, "s",
"总预测数据量:", len(all_text), "平均预测一条的时间(除去加载模型):", time2 / len(all_text), "s/条")
text | result | standard |
---|---|---|
帮我查下id为4001的全部数据 | {'text': '帮我查下id为4001的全部数据', 'intent': 'QUERY', 'slots': {'id': ['4 0 0# # 1']}} | {'text': '帮我查下id为4001的全部数据', 'intent': 'QUERY', 'slots': {'id': ['4001']}} |
查id是2的数据 | {'text': '查id是2的数据', 'intent': 'QUERY', 'slots': {'id': ['2']}} | {'text': '查id是2的数据', 'intent': 'QUERY', 'slots': {'id': ['2']}} |
给我id为1001bc2的全部数据 | {'text': '给我id为1001bc2的全部数据', 'intent': 'QUERY', 'slots': {'id': ['1 0 0 1# # b c# # 2']}} | {'text': '给我id为1001bc2的全部数据', 'intent': 'QUERY', 'slots': {'id': ['1001bc2']}} |
查id4102 | {'text': '查id4102', 'intent': 'QUERY', 'slots': {}} | {'text': '查id4102', 'intent': 'QUERY', 'slots': {'id':['# # 4 1# # 0 2']}} |
急需看一下id为345的那个记录是啥 | {'text': '急需看一下id为345的那个记录是啥', 'intent': 'QUERY', 'slots': {'id': ['3 4 5']}} | {'text': '急需看一下id为345的那个记录是啥', 'intent': 'QUERY', 'slots': {'id': ['345']}} |
train_loss | intent_avg | slot_avg |
---|---|---|
0.015221006236970425 | 1.0 | 1..0 |
TODO
项目依赖包版本统计
pip freeze > requirements.txt
Dockerfile
FROM python:3.11.0-slim
WORKDIR /app
COPY . /app
RUN pip install --no-cache-dir -r requirements.txt
# pip 源配置
# 模型数据卷持久化
启动项目
docker build -t iia-algo-model-training-image .
docker run -it --name iia-algo-model-training-container iia-algo-model-training-image
模型封装为 HTTP 接口
使用 Flask 将模型封装为 HTTP 接口
pip3 install flask
pip3 freeze > requirements.txt
pip3 install --no-cache-dir -r requirements.txt
python3 app.py
# 使用官方 Python 运行时作为父镜像
FROM python:3.11.0-slim
# 工作目录设置为 /app
WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# 将依赖项复制到容器中并安装
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# 将应用的代码复制到容器中
COPY . /app
# 容器对外暴露的端口号
EXPOSE 5555
# 运行 app.py 当容器启动时
CMD ["python", "app.py"]
from flask import Flask, request, jsonify
import model_loader
app = Flask(__name__)
model = model_loader.load_model()
@app.route('/api/algo', methods=['POST'])
def predict():
data = request.get_json()
print('1123123123', data)
if 'text' in data:
prediction = model.detect(data['text'])
# 回传预测结果
return jsonify({'result': prediction})
else:
return jsonify({'error': "Missing required parameter 'text'"}), 422
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5555)
// HTTP POST
{
"text": "帮我查下id为100的全部数据"
}
// Response
{
"result": {
"intent": "QUERY",
"slots": {
"id": [
"1 0 0"
]
},
"text": "帮我查下id为100的全部数据"
}
}
TODO
将模型处理为服务
使用 Nextjs14 开发示例项目