使用LLM和doccano进行关系抽取和验证
2024-08-27 14:32:53

关系抽取

在企业项目中,需要对制冷这一冷门领域的操作指南和说明文档进行关系抽取。为此笔者尝试了许多传统方法的模型,但效果都欠佳。受到当下在NLP(Natural Language Processing,自然语言处理)中流行且功能强大的LLM(Large Language Model,大语言模型)的启发,笔者尝试了使用本地和API的大模型进行关系抽取,取得了相对于传统方法较好的效果。

使用传统深度学习方法的关系抽取的不足

起初,笔者尝试了使用传统深度学习方法进行关系抽取。基本思路是使用IDCNN/biLSTM和CRF进行实体检测,然后使用biGRU和attention机制进行特征提取。但是传统深度学习方法有两个基本的问题:

  • 在冷门领域不具备zero-shot能力。训练这些模型的训练集(如DUIE)相对于LLM来说还是太少,而且预训练数据显然没有覆盖到制冷领域相关的知识。于是这些模型在制冷领域中都不能够提取出关系。
  • 可用的训练数据较少。制冷领域可用的有标签训练数据太少,不足以训练出可用的模型。

笔者测试了DuIESiameseUniNLU等主流模型,都无法在企业项目的文档中提取出任何的关系。

使用 LLM进行关系抽取

当下,大语言模型得到了越来越多的使用,同时开源大语言模型的效果也在逐步提高。论文Zero-Shot Information Extraction via Chatting with ChatGPT 中的研究表明:在两个语言的6个数据集上的实验结果表明,使用ChatGPT等LLM的关系抽取取得了非常好的效果,甚至在几个数据集上(例如NYT11-HRL)上超过了全监督模型的表现。出于这篇论文的启发,笔者使用LLM进行关系抽取。

LLM模型大小的影响

首先,笔者在文心一言的网页对话上进行了初步尝试,发现了这个思路是可行的。

但是,商用大模型由于参数量更大、使用的计算资源更多、训练数据也更大,本地的开源大模型效果肯定比不上商用API。因此笔者在本地部署了一些主流的开源大模型,以测试是否可行。

笔者在ChatGLM3-6B-Chat,Baichuan2-13B-Chat和Baichuan2-13B-Chat-4bits量化版上都进行了实验,发现模型参数量的大小会显著地影响得出的效果。在企业项目的文档中,对于笔者准备好的相同的prompt,ChatGLM3-6B-Chat的关系抽取效果一般,难以提取出完整实体。Baichuan2-13B-Chat-4bits量化版本表现则更优,已经可以提取出较为准确的实体和关系,精度更高的Baichuan2-13B-Chat模型效果也符合预期地达到了更佳的表现。同时,商用大模型文心一言网页版得到了最理想的效果。通过实验可以发现,基本上关系抽取的效果是与LLM本身的能力(通常和参数大小正相关)是成正相关的。

Prompt编写

参考了论文Zero-Shot Information Extraction via Chatting with ChatGPT中prompt的编写方式,以及在Baichuan2上的实验,最终对于无schema的关系提取,笔者得出这个模板:

1
2
假设你是一个实体关系抽取模型,你需要抽取出接下来用三重反引号限定的句子中成对的关系实体。该句子来源于{context}中。抽取出的每个关系需要完全符合{{"subject":主体, "subject_type":"主体类型", "relation":"联系", "object":"客体", "object_type":"客体类型"}}的JSON格式,主体类型、联系和客体类型的值都必须是中文,主体和客体必须完整摘自句子中,主体和客体越长、越完整越好。最后只输出符合JSON格式的结果。
```{line}```

这里对该prompt进行说明:

Prompt中的{context}可以是文档名字,或者这个文档的主体内容。经过实验,笔者发现给定文档名称可以让LLM更好地理解文档中的句子。

接下来笔者指定了LLM输出JSON格式的结果。通过指定找出的关系所需的格式,LLM可以给出一个较为标准的回答,便于后续的处理。而且经过实验笔者发现,在有了给定的回答格式之后,LLM的关系抽取表现也会变好。

同时笔者在实验过程中发现,LLM在遇到文档中的英文部分时,回答可能会以英文形式给出,因此笔者在prompt中限定了输出需要是中文。需要注意的是,较小的模型即使给定了这个限定,也可能不会得到像预期一样的输出。

笔者在这里加上主体和客体必须完整摘自句子中的指令,这是因为笔者之后需要把输出内容上传到doccano标注工具上,笔者需要保证实体和客体出自原句,以便找出其在原句中的位置。

Prompt中还强调了主体和客体越长、越完整越好,这个是可选的选项,经过实验笔者发现,如果加上这句话,LLM会从一个更大局的角度来找出句子中的关系,这更符合企业项目中说明文档关系提取的需求。如果去掉这句话,找出来的关系会偏向于局部的关系。需要注意的是,对于Baichuan2-13B这类较小的LLM,由于模型理解能力有限,它可能还是无法提取出符合句子中心思想的关系。但是文心一言等商用大模型就可以做到。

数据处理

由于LLM可以直接以自然语言作为输入,数据预处理工作稍微简化了一些。笔者需要考虑的问题是,对于较长的输入文本,是应该以段落还是句子为标准作为关系抽取的输入。以段落为标准进行关系抽取可以让LLM更全面地了解一个句子的意思,而且可以降低LLM的指示prompt和输入prompt的比值,提高推理效率。但是,这种方法需要占用较大的显存,一些机器可能不能满足这样高的显存要求。另外,如果是要提取文本段的核心关系,考虑是以段落还是以句子为标准,可以从不同粒度提取想要的结果。经过测试,两种标准都能有较好的关系抽取效果。因此,在机器显存足够的情况下,使用段落作为标准进行关系抽取效果更好。

读取文档后,再去除空行,以避免计算资源的浪费。

1
2
3
data_file = open("./2023/11/29/RE/data/data_0.txt", "r", encoding="utf-8")
data = data_file.readlines()
data = [line for line in data if line != "\n"]

笔者让LLM输出符合JSON格式的结果,以便下游任务使用。

但是对于LLM,尤其是本地部署的参数较小的LLM,即使在prompt中明确给出了任务要求的输出格式,仍然会出现输出不符合要求的情况,如果不进行处理会对下游工作造成一定的麻烦。

经过实验,笔者发现LLM可能会出现以下形式的非法输出:在开始或结尾输出类似“好的,接下来我将进行关系抽取任务”的冗余信息;在最后一个JSON对象后加上逗号;输出的[]号不匹配;输出的JSON对象之间没有逗号;输出不符合要求的键值对;由于提取不出关系,输出的内容中不包含JSON。

起初笔者尝试了使用python的字符串删减,替换方式来处理,但是通过实验发现,对于LLM输出的不稳定结果,这种操作方法的鲁棒性太差。后来笔者发现,既然笔者的核心目标是LLM输出的JSON语句,那么笔者实际上可以直接提取符合JSON格式的语句段。

1
relations = re.findall(r'({\s*?\n\s*?"subject":.*?,\s*?"subject_type":.*?,\s*?"relation":.*?,\s*?"object":.*?,\s*?"object_type":.*?\s*?})', response)

这个正则表达式会匹配(几乎)符合要求的JSON对象,由于输出的内容中可能不包含JSON,需要再加上控制判断。

然后用replace来解决最后一个JSON对象后加上逗号的问题。

1
response = re.sub(r",\s*?\n?\s*?}", "\n}", response)

找到符合条件的JSON对象后,只需要再用python的字符串方法把它们拼接起来,最终就可以形成一个可用的JSON。

基于schema和无schema的关系抽取

在关系抽取任务中,schema,也就是诸如(人名-编写-小说)这样的关系模式,可以用来限制和指导关系抽取模型抽取关系。而LLM相对于传统深度学习的关系抽取模型来说,还拥有无schema进行关系抽取的能力;也就是说,LLM会自动地根据任务需要和上下文提取出合适地关系,而不限定是什么模式。基于schema进行关系抽取可以让LLM抽取出笔者想要的特定模式,适合笔者有明确的抽取目标的场景;而无schema的关系抽取可以节省相关人员的工作量,也可以更好地发挥大模型的理解能力,缺点是抽取出的关系和实体标签可能比较繁杂。

通过实验笔者发现,对于企业项目中说明文档性质的内容,在使用Baichuan2-13B模型的情况下,如果使用基于schema的关系抽取,LLM回答的内容的置信度会下降很多,经常出现强行匹配schema的情况,而且即使是符合schema的关系,效果也只是一般。而如果不使用schema则不会有这种情况发生,因此笔者默认使用无schema的模式,如果要使用基于schema的模式,可以参考ChatIE中的Prompt。

其他问题

如果使用Baichuan2-13B等本地开源模型,且机器性能一般,只能勉强运行的话,对于较长的句子,LLM可能会卡死,即使过了十几二十分钟也不会输出结果。因此,笔者使用了timeout_decorator库来为推理设置超时时间(只在Linux平台上有效)。同时在JSON文件记录了输出对应于原始文本的位置index。

因为长文本处理随时都有可能因为各种异常原因暂停,笔者把程序断开时所在的index记录为check_point,以使得程序在断开后能从断开的句子处继续写入。

由于大模型输出结果的不稳定性,最后的文档可能还不是标准的JSON格式,需要再进行JSON校验。

doccano标注

由于不管是传统深度学习模型还是大语言模型,都无法完美地提取出期望的结果,笔者使用了开源的文档标注工具doccano,以实现人在回路进行关系的校验和增删。由于要将LLM的不稳定输出提交到需要稳定输入的doccano标注工具上,笔者编写相关转换和处理的Python代码。

doccano客户端

连接doccano需要token鉴权,因此首先需要知道doccano部署的ip和端口,以及username和password。笔者使用下面的代码来进行客户端的初始化。

1
2
3
4
5
6
7
8
import requests
class Client(object):
def __init__(self, entrypoint, password):
self.entrypoint = entrypoint
self.client = requests.Session()
self.client.auth = (username, password)
token = self.client.request("POST",f'{self.entrypoint}/v1/auth/login/', json={'username': username, 'password': password}).cookies['csrftoken']
self.client.headers.update({'X-CSRFToken': token})

Client类中定义的其他方法用于对doccano客户端的增删改查操作。

在Doccano中,一个项目(project)是拥有共同标签集的文本集的概念,一个文档(document)是一个用于标注的文本。标签(labels)包括实体标签和联系标签,实体标签和关系标签分别用span-type和relation-type表示。一个标注实体用(span)表示,记录相关实体的起始和结束偏移量,以及实体标签;一个标注关系用(relation)表示,记录头实体,尾实体和关系标签。

数据处理

首先,笔者需要对数据处理中得到的JSON格式输出进行人工确认,确保符合JSON格式,一般只需要修改几处异常即可(其中,重复的键值对无需处理,Python的json库自动取最后一个值)。

笔者还需要读取原始文本,然后指定文章的划分方法,这里一般是换行符或者句号。

接下来是读取JSON格式的输出文本,然后需要删除不符合条件的关系。笔者删除掉缺少由prompt指定的必要的JSON关键字的响应,因为这通常隐含了这个提取效果不够好,才会缺少某一个关键字。同样的,当键值对的值不为字符串、键值对的值为空时,笔者也删除掉这个关系。笔者不关注多余的键值对(例如,LLM会提取出“evidence”,“object_part”这样不符合要求的键),因为笔者直接忽视这部分即可(也提供了像上述一样删除的选项)。

上传标签

如果之前没有上传过标签,笔者就把JSON输出的所有实体类型、关系、联系类型都上传到doccano服务器上。Doccano会自动屏蔽掉重复的标签,所以无需额外处理。如果要删除标签,可以调用client的响应函数。

获取标签id

在doccano中,笔者是通过标签的id来操作标签的,而不是用标签的名称。而id又是doccano指定的,所以笔者需要事先从doccano获得标签,并把标签和对应文本的键值对保存到内存里,以便后续上传实体和关系时使用。

上传实体和关系

实体位置的计算

上传实体时最主要的问题就是实体位置的计算。因为doccano记录实体在文章中的位置,以便于进行检验和修改,所以笔者需要上传正确的实体位置。

这一部分工作(还有之前相当一部分的工作)笔者没有交给LLM来做,因为这类有确切结果,且容易编写算法求解的工作,编写对应的Python脚本来解决,可以既保证结果的准确性又减少算力的浪费。

笔者使用一个变量global_offset来记录实体在文章中的实际偏移量。对于LLM输出的JSON文档中对于每一个文本段给出的所有回复,笔者都尝试找出这个回复中的实体和客体第一次出现在这个文本段的位置(因为同样名称的实体在一个文本段中几乎可以肯定是相同含义的),如果找不到这个实体(通常是因为LLM的模型较小,而忽视了笔者的要求),笔者通过匹配前若干个相同的字符来找到文本段的位置(同样是因为文本段较小,几乎不会找错专有实体)。如果确实找不到这个实体(几乎不会出现),笔者也就只能跳过这个句子。最终,这个实体的实际起始偏移量和实际结束偏移量结合global_offset和实体的长度就都可以计算出来了

在每个句子处理完后,笔者把global_offset加上这个句子的长度。需要注意的一个细节是,由于LLM可能对于某些文段没有输出,笔者需要把跳过对应的句子的长度也都加起来。

重复实体的处理

对于每个句子,一个实体可能会在多个关系中出现,而doccano不会对重复的实体进行过滤(这是因为一个实体可能有多个类型,但是实际上doccano对于相同的类型也不会过滤),因此笔者需要判断是否已经上传过了这个实体,否则标注的结果会很混乱。这里判断相同的依据是:起始偏移量、结束偏移量和标签都相同。

这样做还有一个额外的好处。当程序因为各种原因断开时,笔者可以简单地从头执行这个程序,因为重复的实体不会被重复上传。

上传

所有的上述工作做完后,笔者就可以上传实体和关系了。上传实体时只需要上传初始、结束偏移量和实体标签id,上传关系时只需要上传头尾实体id和标签id。对于重复的关系,doccano会自动过滤掉。