“Life is short, you need Python. —— Bruce Eckel。”
前面一篇文章,我们已经尝试做了一件非常有意思的事情:
从自然语言文本中,借助现成工具,抽取出三元组。
例如,句子:
我们希望得到的结果是:
到这里,其实我们已经向知识图谱迈进了一大步。因为知识图谱中最基础、最经典的数据形式,本来就是三元组。
不过,这里还有一个很自然的问题:
如果手里已经有了很多三元组,下一步该怎么办?
比如,现在我们有下面这些数据:
(曹雪芹, 作者, 红楼梦)(鲁迅, 作者, 狂人日记)(北京大学, 位于, 北京)(清华大学, 位于, 北京)
如果只是把它们写在纸上,或者保存在表格里,当然也是可以的。
不过,知识图谱之所以叫“图谱”,一个非常关键的原因就在于:
它不只是“三元组的集合”,还要把这些三元组真正组织成“图”。
这一篇,我们就来做这件事。
先不要急着写代码:先想清楚“图”到底是什么
如果你以前没有接触过图论,也没有关系。这里我们尽量用最朴素的方式去理解。
在知识图谱里,我们常常会听到两个词:
这两个词第一次看,可能有一点抽象。我们先慢慢理解。
1. 节点是什么?
你可以把节点理解成:图里面的“点”
在知识图谱里,节点通常对应的是“实体”。
例如:
这些都可以看作节点。
2. 边是什么?
边可以理解成:两个点之间的连接
在知识图谱里,这种连接通常对应的是“关系”。
例如:
曹雪芹 —— 作者 —— 红楼梦
北京大学 —— 位于 —— 北京
这里,“作者”和“位于”其实就是边上的信息。
3. 所以三元组和图的关系是什么?
现在,我们把三元组再看一遍:
例如:
其实它本身就已经非常接近图了。因为它表示的是:
从 曹雪芹 这个节点
通过 作者 这个关系
指向 红楼梦 这个节点
也就是说:一个三元组,其实就可以看作图中的一条边。
如果有很多条三元组,把它们全部放在一起,就会逐渐形成一个图结构。
这里我们选择一个非常常见、非常适合入门的工具:
networkx
如果是第一次看到这个名字,也不用担心。你现在只需要先记住:
networkx 是 Python 中一个处理图结构很常见的工具包。
为什么这里先用它,而不是其他更复杂的图数据库或者图系统呢?
原因很简单。对于零基础读者来说,我们现在最需要的是:
先理解“图”是什么
先学会在 Python 里把图建起来
先会做一些简单查询
而 networkx 在这些方面非常合适:
所以这一篇,我们先不用图数据库,先用 networkx 把最核心的概念走通。
如果你的环境里还没有安装 networkx,一般可以这样安装:
这里和之前安装其他包的方式是一样的:
pip:Python 的包管理工具
install:安装
networkx:包名
安装完成后,就可以在 Python 里导入它。
第一步:导入工具
这一行的意思是:
导入 networkx
并且给它起一个更短的别名 nx
这和前面我们写:
其实是同样的思路。
为什么要起别名呢?
因为后面会经常用到它。如果每次都写 networkx,会稍微长一点。写成 nx 会更方便。
所以以后你看到:
或者:
就知道它们都是在调用 networkx 里的功能。
第二步:创建一个图
我们先来看最简单的一句:
这一句很重要,我们慢慢说。
1. 等号左边的 G 是什么?
G 只是一个变量名,用来保存这个图对象。
为什么很多教程都喜欢用 G 呢?
因为 G 可以理解成 Graph 的首字母。这样看起来比较直观。
当然,如果你愿意,也可以写成:
也是一样的。
2. DiGraph() 是什么?
这里的 DiGraph,可以拆开理解:
Graph:图
Di:directed,表示“有方向的”
也就是说:
表示创建一个有向图。
什么叫“有向图”呢?就是边有方向。
例如:
这里方向很重要,因为:
所以,在知识图谱里,关系通常是有方向的。
因此,用 DiGraph() 很自然。
你可以先记住一句话:
知识图谱里的三元组,一般更适合用“有向图”来表示。
第三步:往图里添加一条边
既然一个三元组可以看作图中的一条边,那我们就不妨先加一条最简单的三元组进去。
例如:
import networkx as nxG = nx.DiGraph()G.add_edge("曹雪芹", "红楼梦", relation="作者")
这一句:
G.add_edge("曹雪芹", "红楼梦", relation="作者")
是整件事情的关键。
我们一点一点看。
1. add_edge 是什么意思?
顾名思义,add_edge 的意思就是:添加一条边
也就是说,在图里把两个节点连接起来。
2. 前两个参数表示什么?
这里表示:
因为我们用的是有向图,所以默认可以理解为:
3. relation="作者" 表示什么?
这一部分非常重要。
它表示:给这条边附加一个属性,属性名叫 relation,值是 "作者"
也就是说,这条边不只是“从曹雪芹连到红楼梦”,它还带着一条说明:
这条连接所表示的关系,叫做“作者”
所以,如果把这整句代码翻译成日常语言,大概就是:
在图里加一条从“曹雪芹”指向“红楼梦”的边,并且说明这条边的关系是“作者”。
第四步:一次加入多条三元组
现实中当然不会只有一条三元组,所以我们不妨把几条一起加进去。
先准备一个列表:
triples = [ ("曹雪芹", "作者", "红楼梦"), ("鲁迅", "作者", "狂人日记"), ("北京大学", "位于", "北京"), ("清华大学", "位于", "北京")]
这里 triples 是一个列表,里面的每一项都是一个三元组。
注意,这里三元组的顺序是:
也就是:
第一个位置:头实体
第二个位置:关系
第三个位置:尾实体
然后,我们可以用 for 循环依次加入图中:
import networkx as nxG = nx.DiGraph()triples = [ ("曹雪芹", "作者", "红楼梦"), ("鲁迅", "作者", "狂人日记"), ("北京大学", "位于", "北京"), ("清华大学", "位于", "北京")]for head, relation, tail in triples: G.add_edge(head, tail, relation=relation)
这一段里,最值得慢慢看的是第9行:
for head, relation, tail in triples:
它的意思是:
从 triples 这个列表中
每次取出一个三元组
把这个三元组里的三个部分,分别赋给变量:
例如第一次循环时,相当于:
head = "曹雪芹"relation = "作者"tail = "红楼梦"
然后执行:
G.add_edge(head, tail, relation=relation)
也就是:
G.add_edge("曹雪芹", "红楼梦", relation="作者")
这样,所有三元组就都会被加入图中。
现在图已经有了,但我们总不能凭感觉猜。
所以接下来,我们要学会看一看图里面到底存了什么。
1. 查看所有节点
这会输出图中的所有节点。
结果大致可能是:
['曹雪芹', '红楼梦', '鲁迅', '狂人日记', '北京大学', '北京', '清华大学']
这说明,图中的节点就是我们之前在三元组里出现过的那些实体。
你可以先这样理解:
只要某个实体出现在三元组中,它就会成为图中的一个节点。
2. 查看所有边
print(G.edges(data=True))
这里稍微详细一点解释。
G.edges() 表示查看图中的所有边。
而里面的参数:
表示:不仅查看边连接了哪些节点,还要把边上带的属性也一起显示出来。
所以结果可能像这样:
[ ('曹雪芹', '红楼梦', {'relation': '作者'}), ('鲁迅', '狂人日记', {'relation': '作者'}), ('北京大学', '北京', {'relation': '位于'}), ('清华大学', '北京', {'relation': '位于'})]
这一行如果第一次看觉得有点长,也没关系。你可以慢慢拆:
每一项其实包含三部分:
1. 起点节点
2. 终点节点
3. 一个字典,里面存着边的属性
例如:
('曹雪芹', '红楼梦', {'relation': '作者'})
意思就是:
到这里,我们其实已经真正“把三元组组织成图了”
前面几篇,我们一直在说三元组。
这一篇到了这里,第一次真正把“三元组集合”变成了“图对象”。
这一点非常重要。
因为从现在开始,我们手里拿着的就不只是:
而是一个真正的图结构对象。
而图结构最大的意义在于:
我们可以围绕“点”和“边”去做查询和分析。
接下来,我们就来做最简单、最常见的几种查询。
这里的“查询”,你可以先理解成:
我想问图几个问题,然后让 Python 告诉我答案。
例如:
图中有哪些节点?
某个节点和谁相连?
某个节点有什么邻居?
某两点之间有没有路径?
这些都可以理解成“简单查询”。
对于零基础读者来说,我们先从最容易直观理解的开始。
例如,我们想知道:
北京大学 这个节点,连向了谁?
可以这样写:
print(list(G.neighbors("北京大学")))
这一句的意思是:
G.neighbors("北京大学"):查看 北京大学 的邻居节点
list(...):把结果转成列表,方便打印出来看
输出大致会是:
这说明:
北京大学 在图中有一条边,指向 北京
也就是我们之前那条三元组:
如果你问:为什么这里要加 list() 呢?
这是因为 neighbors() 返回的并不是一个最直观的普通列表,而是一个可遍历对象。
对于初学者来说,把它转成列表再打印,会更容易看懂。
所以你现在可以先记住:
想直接看邻居有哪些,常常写成 list(G.neighbors(...))
有时候,我们不只是想知道它连向了谁,还想知道:
它通过什么关系连过去的?
这时候,就可以查看某个节点发出的边。
例如:
print(G.out_edges("北京大学", data=True))
这里:
out_edges 表示“从这个节点出去的边”
"北京大学" 是我们要查询的节点
data=True 表示把边的属性也显示出来
输出可能是:
[('北京大学', '北京', {'relation': '位于'})]
这说明:
这就比刚才只看邻居更完整一些了。
既然有“出边”,自然也有“入边”。
例如,现在我们想问:
有哪些节点指向了 北京?
可以这样写:
print(G.in_edges("北京", data=True))
输出可能是:
[ ('北京大学', '北京', {'relation': '位于'}), ('清华大学', '北京', {'relation': '位于'})]
这就说明:
也就是说,图中有两条“位于”关系都指向了 `北京`。
这一类查询非常常见,因为它能帮助我们回答:
谁和这个实体有关?
有时候,我们会想先确认一下某个实体是不是已经在图里了。
可以这样写:
输出会是:
这表示:
鲁迅 这个节点在图中存在
如果写:
而图里没有 莫言,那就会得到:
这一点和我们前面学过的列表、字典中的 in 判断,其实很像。
所以它并不陌生。
如果我们想问:
图中有没有从“曹雪芹”到“红楼梦”的边?
可以这样写:
print(G.has_edge("曹雪芹", "红楼梦"))
输出会是:
如果我们问:
print(G.has_edge("红楼梦", "曹雪芹"))
那么在当前这个有向图里,结果通常会是:
这里正好可以帮助我们再一次理解“方向”的意义。
因为图中存的是:
而不是反过来。
所以:在有向图中,边的方向是有意义的。
这一点非常重要。
接下来,我们学习一个更神奇的功能:查询两个节点之间是否有路径
这是图结构一个很有意思的地方。
例如,我们假设图里不只有直接相连的边,还可能通过中间节点间接连起来。
那么我们就会关心:
从一个节点出发,能不能走到另一个节点?
这就是“路径”问题。
在 networkx 中,可以这样判断:
print(nx.has_path(G, "北京大学", "北京"))
输出会是:
因为图中有一条直接的边:
如果问一个不存在路径的情况,例如:
print(nx.has_path(G, "红楼梦", "北京"))
那么结果可能是:
因为在当前图里,并没有一条从 红楼梦 通往 北京 的路径。
如果两个节点之间真的有路径,我们还可以看看:
最短路径是什么?
例如:
print(nx.shortest_path(G, "北京大学", "北京"))
输出大致会是:
因为这两个节点是直接相连的,所以最短路径就是:
如果以后图更复杂,中间会经过多个节点,那么这个功能会更有意思。
对于零基础读者来说,现在只需要先有一个印象:
图不只是“存关系”,还可以“沿着关系往下走”。
这正是图结构和普通表格结构一个很不一样的地方。
前面一篇,我们已经学过如何借助现成工具,从文本中抽取三元组。
这一篇最自然的延续,就是:
把抽取出来的三元组,直接送进图里。
例如,假设我们现在已经有了一个三元组列表:
triples = [ ("牛顿", "发现", "万有引力"), ("哈佛大学", "位于", "美国"), ("莫言", "创作", "红高粱")]
那么构图的方式其实和前面完全一样:
import networkx as nxG = nx.DiGraph()for head, relation, tail in triples: G.add_edge(head, tail, relation=relation)print(G.nodes())print(G.edges(data=True))
你会发现,这一步其实非常自然。
也就是说,前一篇的结果,可以直接作为这一篇的输入。
前面我们也学过用 pandas 处理表格。
所以如果三元组已经在 CSV 文件里,例如:
head,relation,tail曹雪芹,作者,红楼梦鲁迅,作者,狂人日记北京大学,位于,北京
我们也完全可以先读进来,再构图。
代码如下:
import pandas as pdimport networkx as nxdf = pd.read_csv("triples.csv", encoding="utf-8")G = nx.DiGraph()for _, row in df.iterrows(): head = row["head"] relation = row["relation"] tail = row["tail"] G.add_edge(head, tail, relation=relation)print(G.nodes())print(G.edges(data=True))
这一段里,可能最陌生的是:
for _, row in df.iterrows():
我们慢慢说。
df.iterrows() 是什么?
它的作用是:一行一行地遍历 DataFrame
也就是说,每次循环拿出表格中的一行。
_ 和 row 分别是什么?
iterrows() 每次会返回两个东西:
1. 行号
2. 这一行的数据
这里我们暂时不关心行号,所以用 _ 接住它。
在 Python 里,_ 常常表示“这个值我先不太关心”。
而 row 表示当前这一行的数据。
所以:
head = row["head"]relation = row["relation"]tail = row["tail"]
就是从这一行中取出三列:
然后再用它们构图。
这样一来,就把表格里的三元组全部送进图里了。
现在,我们可以发现,如果把这一篇的教程尽量简单地总结,其实可以归纳成一句话:
三元组里的两个实体,可以看作两个节点;
中间的关系,可以看作一条带属性的有向边。
只要这一句理解了,后面的很多代码都会显得顺理成章。
下面我们把这一篇最核心的流程,整理成一段完整代码。
建议零基础读者可以完整看一遍,然后再慢慢拆。
import networkx as nx# 第1步:创建一个有向图G = nx.DiGraph()# 第2步:准备三元组数据triples = [ ("曹雪芹", "作者", "红楼梦"), ("鲁迅", "作者", "狂人日记"), ("北京大学", "位于", "北京"), ("清华大学", "位于", "北京")]# 第3步:把三元组加入图中for head, relation, tail in triples: G.add_edge(head, tail, relation=relation)# 第4步:查看图中的节点print("所有节点:")print(list(G.nodes()))# 第5步:查看图中的边print("所有边:")print(list(G.edges(data=True)))# 第6步:查看某个节点的邻居print("北京大学的邻居:")print(list(G.neighbors("北京大学")))# 第7步:查看某个节点发出的边print("北京大学的出边:")print(list(G.out_edges("北京大学", data=True)))# 第8步:查看哪些节点指向北京print("北京的入边:")print(list(G.in_edges("北京", data=True)))# 第9步:判断某个节点是否存在print("图中是否有鲁迅:")print("鲁迅" in G.nodes)# 第10步:判断某条边是否存在print("图中是否有从曹雪芹到红楼梦的边:")print(G.has_edge("曹雪芹", "红楼梦"))# 第11步:判断是否有路径print("北京大学到北京是否有路径:")print(nx.has_path(G, "北京大学", "北京"))# 第12步:查看最短路径print("北京大学到北京的最短路径:")print(nx.shortest_path(G, "北京大学", "北京"))
这一篇,我们没有再停留在“三元组是三元组”这个层面,而是开始把它组织成图。
我们主要做了这些事情:
使用 networkx 创建有向图
把三元组加入图中
理解节点和边分别对应什么
查看图中的节点和边
做一些非常基础的查询
如果把前几篇和这一篇连起来看,你会发现我们已经慢慢走出了一条很完整的路线:
文本 → 三元组 → 表格 → 图结构 → 简单查询
这其实已经越来越接近知识图谱最核心的使用方式了。
现在不妨试一试下面这个小练习。
给你这些三元组:
triples = [ ("莫言", "创作", "红高粱"), ("红高粱", "类型", "小说"), ("哈佛大学", "位于", "美国"), ("美国", "属于", "北美洲")]
请你尝试完成下面几件事情:
1. 创建一个有向图
2. 把这些三元组加入图中
3. 打印图中的所有节点
4. 打印图中的所有边及其关系
5. 查看 哈佛大学 的邻居
6. 判断从 哈佛大学 到 北美洲 是否存在路径
7. 如果存在,打印这条最短路径
答案
import networkx as nx# 第1步:创建有向图G = nx.DiGraph()# 第2步:准备三元组triples = [ ("莫言", "创作", "红高粱"), ("红高粱", "类型", "小说"), ("哈佛大学", "位于", "美国"), ("美国", "属于", "北美洲")]# 第3步:把三元组加入图中for head, relation, tail in triples: G.add_edge(head, tail, relation=relation)# 第4步:打印所有节点print("所有节点:")print(list(G.nodes()))# 第5步:打印所有边及其关系print("所有边:")print(list(G.edges(data=True)))# 第6步:查看哈佛大学的邻居print("哈佛大学的邻居:")print(list(G.neighbors("哈佛大学")))# 第7步:判断是否存在路径has_path = nx.has_path(G, "哈佛大学", "北美洲")print("哈佛大学到北美洲是否有路径:")print(has_path)# 第8步:如果存在路径,就打印最短路径if has_path: print("最短路径是:") print(nx.shortest_path(G, "哈佛大学", "北美洲"))
参考答案说明
我们再把作业答案简单说一下。
为什么 哈佛大学 到 北美洲 会有路径?
因为图中有两条相关边:
哈佛大学 ——>位于——> 美国美国 ——>属于——> 北美洲
所以从 哈佛大学 出发,可以先到 美国,再到 北美洲。
因此,路径存在。
那么,最短路径会是什么?
输出大致会是:
这表示: