“Life is short, you need Python. —— Bruce Eckel。”
前面的几篇文章,我们已经一起学习了不少内容。
我们学过:
变量、判断、循环
列表、字典、方法
类和对象
文件读写
JSON 和 pandas
如果把这些内容连起来看,其实我们已经能够做很多事情了。比如:
把数据从文件里读进来
把数据存进列表、字典或者表格
对这些数据进行简单整理和统计
不过,到目前为止,我们处理的大多还是一种“比较理想”的数据。所谓“比较理想”,意思是:这些数据本身已经整理好了。
例如,像下面这样的内容:
曹雪芹 作者 红楼梦鲁迅 作者 狂人日记北京大学 位于 北京
它已经是三元组的形式了。
也就是说,我们一拿到它,就知道:
这样的数据当然很方便。
可是,在真实的知识图谱研究或应用中,事情往往不是这样开始的。
更常见的情况,其实是这样的:
曹雪芹是《红楼梦》的作者。鲁迅写了《狂人日记》。北京大学位于北京。
你会发现,这个时候我们手里拿到的,不再是整整齐齐的三元组,而是一句一句自然语言文本。
那么,一个很自然的问题就来了:
这些三元组,究竟是怎么从文本里来的?
这一篇,我们就来尝试回答这个问题。
不过,这里我想先说明一下思路。
如果是比较早期的一些做法,或者一些很传统的教材,往往会先讲:
这些方法当然并不是完全没有价值,在某些特定场景下仍然可以使用。不过,如果我们的目标是让零基础读者尽快接触今天更常见的技术路线,那么与其花很多力气去手写规则,不如先学会:如何调用已经存在的工具,让工具帮我们完成信息抽取。
这样更自然,也更符合现在的实际情况。
所以这一篇,我们不需要自己写规则,而是直接借助现成工具,尝试从文本中抽取三元组。
在知识图谱中,最经典的表示方式就是三元组:
或者写成:
例如:
(曹雪芹, 作者, 红楼梦)(北京大学, 位于, 北京)
所以,如果我们面对一句文本,比如:
我们最终想要的,就是从这句话里得到这样的结构化结果:
你可以发现,这里面其实包含了三件事情:
第一件事:找到实体
例如:
第二件事:理解关系
例如:
第三件事:把这三部分组织起来
最终变成:
所以,所谓“从文本中抽取三元组”,本质上做的就是这三步。
以前,人们常常试图自己去定义这三步该怎么完成。现在,更常见的思路是:把这件事交给模型,让模型直接帮我们做。
这里我们会接触一个非常常见的 Python 工具包,名字叫:
这个名字第一次看到,可能会让人觉得有一点陌生。不过不必紧张,我们现在不需要深入它的内部原理,只需要先知道它大概是做什么的。
你可以先这样理解:
transformers 是一个可以帮助我们方便地调用各种预训练模型的工具包。
这里又出现了一个新词:“预训练模型”。
如果你是零基础,可以先不用纠结这个概念的完整定义。我们只要先有一个比较朴素的理解就可以:
所谓“预训练模型”,就是别人已经训练好、我们可以直接拿来使用的模型。
也就是说,我们并不是从零开始训练一个模型,而是:
别人已经准备好了模型
我们负责把文本交给它
它负责给我们结果
这样理解就足够了。
如果你还没有安装 transformers,一般需要先安装它。
在命令行或者终端里,可以输入:
这里简单解释一下:
pip 是 Python 中常见的包管理工具
install 表示安装
transformers 是我们要安装的包名
如果你之前安装过别的工具包,比如 pandas,这一步的感觉应该会有一点熟悉。
有些读者如果使用的是 Anaconda,也可能会通过其他方式安装。不过对于初学者来说,先知道 pip install ... 这种形式就可以了。
前面说了这么多,可能还是有点抽象。我们现在直接看代码。
先看一段最简单的形式:
from transformers import pipeline
这一行代码的意思是:
从 transformers 这个工具包里,导入一个叫 pipeline 的工具。
这里的 from ... import ... 是 Python 中一种很常见的写法。
你可以先把它理解成:
我现在不把整个工具包都搬出来,只是从里面拿一个我现在要用的小工具。这个小工具的名字叫 pipeline。
那 pipeline 是什么呢?
这个词原意有“流水线”的意思。放在这里,你可以先理解成:
它是一个“统一入口”,可以让我们比较方便地调用某一类任务的模型。
也就是说,我们不需要自己手动搭很多复杂步骤,而是告诉它:
这对于零基础读者来说,是非常友好的。
先看一段完整代码,再慢慢解释
下面是一段完整但不长的代码:
from transformers import pipelineextractor = pipeline("text2text-generation")text = "请从下面这句话中抽取三元组:曹雪芹是红楼梦的作者。"result = extractor(text)print(result)
如果你第一次看到这段代码,不需要急着完全看懂。我们下面一行一行来解释。
第一行:导入 pipeline
from transformers import pipeline
这一行刚才已经简单说过了。
意思是:
我们现在要使用 transformers
但不是把整个包都展开来用
而是只拿其中一个叫 pipeline 的工具
如果把 Python 想象成一个工具箱,那么 transformers 是一个大工具箱,而 pipeline 就是里面的一把比较常用的工具。
第二行:创建一个抽取器
extractor = pipeline("text2text-generation")
这一行很重要,我们慢慢看。
1. extractor 是什么
等号左边的 extractor,只是一个变量名。
和我们前面学过的一样,它本身没有什么神秘的含义,只是我们给这个工具起了一个名字,方便后面继续使用。
为什么叫 extractor 呢?
因为它是一个“抽取器”。当然,这只是为了让名字更直观。如果你愿意,也可以起成别的名字,比如:
my_tool = pipeline("text2text-generation")
只不过 extractor 这个名字更能提醒我们:它是用来做信息抽取相关事情的。
2. 等号右边在做什么
pipeline("text2text-generation")
这一部分可以理解为:
创建一个能够处理“文本生成类任务”的模型入口。
这里出现了 "text2text-generation" 这个字符串,看起来稍微长一点。
它的字面意思大概是:
为什么这里要用它呢?
因为我们现在要做的事情,本质上就是:
所以,用这种“文本到文本”的形式来做,是比较自然的。
第三行:准备输入文本
text = "请从下面这句话中抽取三元组:曹雪芹是红楼梦的作者。"
这一行很简单,但也很关键。
它说明我们不是直接把一句原始文本裸着扔给模型,而是:把任务说明和原句一起告诉模型。
也就是说,这里输入给模型的,不只是:
而是:
请从下面这句话中抽取三元组:曹雪芹是红楼梦的作者。
这样做的好处是,模型会更清楚我们希望它做什么。
你可以把这理解成:我们在对模型“提要求”。
对于今天很多模型来说,这种方式其实是很常见的。因为模型更擅长理解自然语言指令。
第四行:让模型处理输入
这一行是整段代码里最核心的一步。
它的意思是:
把变量 text 中那段文字,交给刚才创建好的 extractor 去处理。
然后,模型会返回一个结果。
这个结果被我们保存在变量 result 里。
所以,等号左边 result 是一个变量名;等号右边 extractor(text) 表示“调用抽取器处理文本”。
这和我们前面学过的方法(函数)其实很像。
比如前面我们会写:
result = add_numbers(2, 3)
现在只不过变成了:
本质上还是:
第五行:把结果打印出来
这一行没什么特别的,就是把模型返回的内容显示出来。
如果一切正常,你会看到某种类似下面的输出(不同模型会有些差异):
[{'generated_text': '(曹雪芹, 作者, 红楼梦)'}]
这里不要怕它长得有点复杂,我们来慢慢拆。
模型输出为什么看起来有点复杂?
对于零基础读者来说,这一行会有两个地方看着比较陌生:
1. 最外面有一对方括号 []
这说明它是一个列表
2. 列表里面有一对花括号 {}
这说明列表中的第一个元素,是一个字典
也就是说,这个结果的结构其实是:
一个列表,里面装着一个字典
这并不奇怪,因为很多工具包为了格式统一,都会这样返回结果。
如果把它写得更直观一点,大概相当于:
result = [ { "generated_text": "(曹雪芹, 作者, 红楼梦)" }]
现在是不是清楚一点了?
也就是说,模型真正给我们的主要信息,其实就在字典里的这个键下面:
它对应的值就是:
既然模型输出有一层列表,又有一层字典,那我们就不妨把里面真正有用的内容单独取出来。
可以这样写:
triple_text = result[0]["generated_text"]print(triple_text)
这一小段里,有两个很关键的操作。
第一步:result[0]
因为 result 是一个列表,所以我们可以用索引取它的第一个元素。
还记得吗?列表索引从 0 开始。
所以:
表示“取出列表中的第一个元素”。
而这个第一个元素,恰好是一个字典。
第二步:["generated_text"]
既然刚才取出来的是一个字典,那我们就可以继续用字典的键去取值:
result[0]["generated_text"]
这一步的意思就是:
先取出列表中的第一个字典,再从这个字典中取出 "generated_text" 对应的值。
最后,我们把这个值赋给变量 triple_text。
如果打印它,通常就会得到:
现在,我们就离知识图谱中熟悉的三元组形式已经非常近了。
刚才我们只处理了一句话。现实中,我们当然更希望一次处理多句话。
比如:
texts = [ "请从下面这句话中抽取三元组:鲁迅写了狂人日记。", "请从下面这句话中抽取三元组:北京大学位于北京。", "请从下面这句话中抽取三元组:爱因斯坦提出了相对论。"]
这里我们定义了一个列表 texts,里面有三条待处理的文本。
然后我们可以用 for 循环逐条处理它们:
from transformers import pipelineextractor = pipeline("text2text-generation")texts = [ "请从下面这句话中抽取三元组:鲁迅写了狂人日记。", "请从下面这句话中抽取三元组:北京大学位于北京。", "请从下面这句话中抽取三元组:爱因斯坦提出了相对论。"]for text in texts: result = extractor(text) triple_text = result[0]["generated_text"] print("原句:", text) print("抽取结果:", triple_text) print("------")
我们慢慢解释这段代码。
意思是:
从 texts 这个列表中,依次取出每一个元素,每次取出的那一条文本,叫做 text
在循环内部,每次循环做三件事:
1. 用模型处理当前句子
2. 把生成结果取出来
3. 打印原句和抽取结果
所以运行后,大致会看到这样的效果:
原句: 请从下面这句话中抽取三元组:鲁迅写了狂人日记。抽取结果: (鲁迅, 作者, 狂人日记)------原句: 请从下面这句话中抽取三元组:北京大学位于北京。抽取结果: (北京大学, 位于, 北京)------原句: 请从下面这句话中抽取三元组:爱因斯坦提出了相对论。抽取结果:(爱因斯坦, 提出, 相对论)
这里有一点值得注意:
模型可能不会总是原原本本地保留句子里的动词。例如:
原句是“鲁迅写了狂人日记”,模型可能输出“作者”
这其实是好事。因为知识图谱中的关系,往往希望更规范一些。
也就是说,模型不只是机械地抄文本,而是在尽量理解语义,并输出更适合做三元组的关系。
前面我们只是把模型输出打印出来。现在我们再往前走一步:把这些结果整理成 Python 里真正能继续使用的数据。
例如,我们可以把每条三元组先保存成字符串列表:
from transformers import pipelineextractor = pipeline("text2text-generation")texts = [ "请从下面这句话中抽取三元组:鲁迅写了狂人日记。", "请从下面这句话中抽取三元组:北京大学位于北京。", "请从下面这句话中抽取三元组:爱因斯坦提出了相对论。"]triple_results = []for text in texts: result = extractor(text) triple_text = result[0]["generated_text"] triple_results.append(triple_text)print(triple_results)
这里多了一步:
这是一个空列表,用来存放所有抽取结果。
而:
triple_results.append(triple_text)
表示把当前抽取出的结果追加到列表末尾。
最后,triple_results 可能看起来像这样:
[ "(鲁迅, 作者, 狂人日记)", "(北京大学, 位于, 北京)", "(爱因斯坦, 提出, 相对论)"]
这一步非常自然,因为它把模型的输出先统一收集起来了。后面如果要写入文件、保存成表格、继续分析,都会方便很多。
前面我们已经学过 pandas。既然现在已经有了一组抽取结果,完全可以继续把它们整理成表格。
当然,这里有一个小问题:
模型当前给我们的还是一个字符串,比如:
为了做成表格,我们需要把这串文本分成三部分:
如果模型输出格式比较统一,我们就可以做一个非常简单的处理。
例如:
triples = []for triple_text in triple_results: triple_text = triple_text.strip("()") parts = triple_text.split(",") if len(parts) == 3: head = parts[0].strip() relation = parts[1].strip() tail = parts[2].strip() triples.append([head, relation, tail])
这一段稍微慢一点解释。
前面三元组字符串外面有括号,例如:
这里我们想把前后的括号去掉。
去掉之后就变成:
接着,我们按照逗号把它拆开:
parts = triple_text.split(",")
这样得到的 parts 是一个列表,例如:
注意,这里第二项和第三项前面可能带有空格,所以我们后面又用了 strip() 去掉多余空格。
在代码的第5行,也就是
这一步是为了稍微稳妥一点。
因为我们希望它正好被拆成三部分。如果不是三部分,那这条结果可能格式有些特别,我们就先不处理它。
第9行代码
triples.append([head, relation, tail])
是为了把三部分保存成一个小列表,再放进总列表 triples 中。
处理完以后,triples 可能长这样:
[ ["鲁迅", "作者", "狂人日记"], ["北京大学", "位于", "北京"], ["爱因斯坦", "提出", "相对论"]]
这就已经非常接近我们熟悉的数据结构了。
经过了刚才的一系列处理以后,既然 triples 已经是一个“每一项包含三列”的列表,我们就可以把它交给 pandas:
import pandas as pddf = pd.DataFrame(triples, columns=["head", "relation", "tail"])print(df)
这里:
输出大致会是:
head relation tail0 鲁迅 作者 狂人日记1 北京大学 位于 北京2 爱因斯坦 提出 相对论
现在你会发现,我们已经把“文本中的信息”成功变成了“表格中的结构化数据”。
这其实就是知识图谱处理中非常重要的一步。
既然已经有了 DataFrame,就可以很自然地把它写回文件:
df.to_csv("triples_output.csv", index=False, encoding="utf-8")
这里简单解释一下:
"triples_output.csv" 是输出文件名index=False 表示不要把行号保存进去encoding="utf-8" 是为了正常保存中文
写完以后,你就会得到一个 CSV 文件,里面保存的就是模型抽取出的三元组。
这样一来,整个流程就串起来了:
文本 → 模型抽取 → 三元组字符串 → Python列表 → pandas表格 → CSV文件