“李杜文章在,光焰万丈长”,唐诗无疑是中国古代文学最灿烂的篇章之一。现代人发表论文,会互相引用,喝酒吃饭,也经常会谈及谁谁谁是我哥们。作为当时最重要的文学形式,唐代的诗人也经常会在诗文中提及自己的好朋友。杜甫比李白小十一岁,两者相识于杜甫父亲杜闲家中,彼时正是李白因触怒权贵放归山林之时。杜甫在《与李十二白同寻范十隐居》中描绘了两人的亲密关系:“余亦东蒙客,怜君如兄弟。醉眠秋共被,携手日同行”。不仅如此,在两人各奔东西后,杜甫压抑不住对李白的思念,写了多首提及李白的诗。例如,《梦李白》中云:“三夜频梦君,情亲见君意”。能连续3个晚上做梦都梦到李白,可见交情不浅。
通过分析全唐诗中各位诗人之间的“引用”关系,可以描绘出当时诗坛的大致朋友圈图景:谁跟谁熟? 谁是圈子里的带头大哥? 全唐诗有4万多首,人工一首一首地筛查费时费力,这种重复的统计性质的工作正是计算机最擅长的。
本示例的代码和数据整合在一个名为PoetsNetwork的文件夹中。注意:本示例所依赖的全唐诗文本以及《中国历代人物专辑》使用了繁体中文,所以读者在运行代码查询时,如果使用简体中文输入诗人姓名,结果将与预期不符。
本示例内容受开源项目poetry_analyzer的启发。为方便读者理解,作者整理了相关数据并重写了代码。
1. 数据整理与准备
1.1 sqlite数据库
为了便于统计分析以及向读者简单介绍数据库的入门知识和Python访问数据库的方法,本示例使用sqlite数据库来存储相关数据。
对于结构化的数据,如个人的身份信息、银行的交易流水,图书馆的借还记录等,通常都存储在数据库系统中。数据库系统通常运行在一个服务器或者由多个服务器构成的集群中,软件使用者的计算机或者终端直接或者间接地透过TCP/IP访问数据库、查询或存储数据。大型的数据库系统软件有阿里蚂蚁金服的OceanBase、华为的GaussDB、开源的MySQL以及私有的Oracle。
本示例使用的sqlite是一个超级mini版的数据库系统,它本质上是一个运行于软件内部的C语言包。在示例的代码中,数据库的存储文件为data子目录下的data.db。
为了便于查询数据库中的数据,建议读者在VSCode中安装一个名为SQLite的扩展(extension)。作者安装的该软件版本号为0.6,如图1所示。
在VS Code中,打开项目目录PoetsNetwork,并展开data子目录,用鼠标右击data.db,并在弹出菜单中选择Open Database命令即可打开该数据库,如图2所示。
数据库打开后,可以在SQLITE EXPLORER中展开data.db,可以看到,在数据库中有4个表格。鼠标右击表格的名称,选择Show Table命令后可以显示表格中的数据,选择New Query[Select/Insert]命令则可以输入SQL语句操作数据库中的数据。如图3所示。
图4所示为poem表中的数据,可以看到,唐太宗李世民的诗排在全唐诗的最前面。
数据库中的表(Table)都是二维的,每一行称为一条记录(Record),在poem表中,一条记录存储一首唐诗。每一行又可以分为多列(Column),在数据库中,列也称为字段(Field)。peom表的结构如表1所示。
字段名 | 类型 | 用途 |
---|---|---|
id | int | 用一个数字来表示每首唐诗在表中的唯一编号,该字段不可重复 |
title | text | 题名字符串 |
author | text | 作者姓名 |
content | text | 全文 |
text是sqlite数据库中使用的数据类型的名称,读者可以认为它就是Python中的字符串。
1.2 全唐诗整理
在子目录data下有一个名为qts_zht.txt的文本文件,共收录唐诗4万多首。格式如下:
1 | ... |
可以看见,文本文件的每一行是一首唐诗,用空格可分为4个部分,从前至后分别是编号、题名、作者和全文。为了便于后续的数据分析,作者使用下述程序将全唐诗导入sqlite数据库中的poem表 。
1 | #ParseQTSIntoDatabase.py |
这很可能是读者第一次接触与数据库相关的应用,我们先解释代码中的SQL代码。SQL是Structured Query Language的首字母拼写,是专门应用于关系数据库数据查询和操纵的“语言”。
【SQL语句解析】
① DROP TABLE IF EXISTS poem;
如果poem表存在,将其从数据库中删除(包括结构和数据)。当一个字符串中存在多条SQL语句时,语句之间使用分号作分隔。
② CREATE TABLE poem(id INT PRIMARY KEY ASC, title TEXT, author TEXT, content TEXT);
创建poem表,id字段的类型为int,title、author、content字段的类型为text。请注意,id字段同时也被指定为表的主键——PRIMARY KEY,这意味着在这个表里面,每一行的id字段的值不可以重复。对于关系数据库而言,为每个表指定一个主键通常是必要的。ASC是ASCENDING的缩写,即表示升序排列,这里就是指poem表内的各行依其主键键值在表中升序排列。
③ INSERT INTO poem VALUES (?,?,?,?)
用于向表格poem中插入一行数据,即一首唐诗。?在这里的作用类似于替换符,相关代码会将?号用实际数据替换,并形成下述完整的SQL语句:
INSERT INTO poem VALUES (0, ‘峨眉山月歌’,’李白’,’峨眉山月半轮秋,影入平羌江水流。…’)
代码说明
dbCon=sqlite3.connect(dbFileName)创建一个sqlite的数据库连接。 如果期望与一个数据库通信,建立连接是第一步。此处由于是sqlite的嵌入式数据库,所以参数中只提供了数据库的存储文件路径。 如果连接的是mysql等大型数据库,多半还需要提供服务器的ip地址、端口号、用户名和密码等信息。
dbCursor= dbCon.cursor()用于创建一个数据库游标。数据库游标可以用于执行SQL语 句。如果被执行的是一条数据查询语句,那么数据库将返回一个包含多行记录的数据集。而游标可以在这个多行数据集中游走,用于遍历数据集,故得名游标。
dbCursor。executescript(…)用于执行一个由多条SQL 语句构成的SQL 脚本。 这里先删除 了peom表,然后再重新创建了peom表。 事实上,作者这里偷了个懒:考虑到程序运行前 peom表可能存在并拥有数据,重新创建表应该是清空表中全部数据的最快方法。
接下来,代码打开了全唐诗文本文件,然后逐行迭代,使用空格将每行分成4个部分并填 入peom列表。 请注意,如果总共有N首唐诗,那么 peom列表就有N个元素,每个元素是一个元组,元组形如:( 0, ‘留别王维’,’孟浩然’,’寂寂…’)。
dbCursor.executemany(″INSERT INTO poem VALUES (?,?,?,?)″,poems)将poems列表 中的全部元组展开,替换格式字符串中的?号后形成N条 SQL 语句,并执行,将唐诗插入 poem表。
dbCon.commit()用于提交数据库事务。 只有事务提交后,相关的数据库修改才会被确认并写入硬盘文件。想象一笔银行转账交易,如果细分下来,其实包含了几处数据修改,包括减少转出账户的余额、增加转入账户的余额、添加流水日志等。上述多处修改,如果部分成功,部分失败(因断电或系统故障等导致的),数据库里的数据就会出现不一致的情况。为了避免一个完整操作的细分动作部分成功,部分失败,数据库系统通常提供事务 管理功能,只有在事务提交时,之前的一系列数据修改动作才被确认。
dbCon.close()断开与数据库的连接。
上述代码成功执行后,读者可在SQLITE EXPLORER中单击data.db后面的+号图标,如图5所示,并在其中输入图6所示的SQL代码,并执行(选择弹出菜单中选择Run Query命令),可以查得poem表中的数据总行数,并调出前10行数据,如图7所示。
【图6中SQL语句说明】
① SELECT count(*) FROM poem;
从poem表中统计数据总行数,并返回。
② SELECT id, title, author,content FROM poem LIMIT 10;
从poem表中查询数据,包括id、title、author、content字段,只返回前10行。
顺便说明,作者提供的代码包中的data.db文件中,poem表已存在并包含了整理好的唐诗数据。
1.3 诗人名录及别名
要通过对唐诗的检索确定诗人之间的引用关系,并不容易。最大的困难在于古代中国人的别名太多。例如,杜甫,按字称子美,按排行称杜二,按官职称杜工部,有时还甚至被称为老杜。我们下载了哈佛大学编撰的《中国历代人物传记资料库》,由于这个人物资料库中包含中国历代人物,并非特指唐朝诗人。因此,重名太多,例如,可能存在多个王维、李良的情况。因此,如果仅凭全唐诗的作者名,很难在人物资料库中准确定位那个作为诗人的王维以及他的别名王右丞的。还好,我们还有生卒年可以用。唐朝建立于公元618年,灭亡于公元907年。我们删除了那些生卒年明确且与唐朝没有交集的全部人名记录,也删除了仅记录有生年或卒年,但从生年或卒年看明显跟唐朝没关系的人名记录。同时,我们也删除了全部生卒年均不明确的人名记录。经过整理,我们得到了两个数据表,诗人(poet)表(2)和别名(altname)表(3)。
字段名 | 类型 | 用途 |
---|---|---|
id | int | 用作主键,表示一个诗人的唯一编号 |
name | text | 诗人的姓名,如’李白’,’刘禹锡’ |
birthyear | int | 诗人的出生年份,如果为0,表示生年不详 |
deathyear | int | 诗人的死亡年份,如果为0,表示卒年不详 |
字段名 | 类型 | 用途 |
---|---|---|
id | int | 人物在poet表中的id号,由于一个诗人可能拥有多个别名,因此,altname表中的id字段是允许重复的 |
name | text | 人物的别名 |
现在,我们试图查询一下杜甫的别名。分两步进行,首先执行图17-8所示的SQL语句,仍在SQLITE EXPLORER的Query中进行:SELECT * FROM poet WHERE name = ‘杜甫’,得到如图8所示的执行结果:
我们看到,杜甫在poet表中的id为3915,他的生卒年分别为公元712和770。第二步,使用3915这个id去altname表中查别名: SELECT * FROM altname WHERE id = 3915,查询结果如图9所示。子美、工部都是我们熟悉的,老杜这个别名有点出人意料。
1.4 引用关系
为了保存诗人之间的引用关系,我们还创建了一个名为 reference的表,如表4所示。
字段名 | 类型 | 作用 |
---|---|---|
authorid | int | 诗的作者在poet表中的id |
refid | int | 被引用的诗人在poet表中的id |
poemid | int | 诗在poem表中的id |
李白在《黄鹤楼送孟浩然之广陵》一诗中引用了孟浩然,李白在poet表中的id号为32 540,孟浩然在poet表中的id号为93 956,《黄鹤楼送孟浩然之广陵》在poem中的id号为7 235,故reference表中存在一行记录,其值如图10所示。
上述李白、孟浩然和诗的id号可以通过下述SQL语句查询而得。
1 | select * from poet where name = '李白'; |
请读者注意,SQL语句当中的关键字是不区分大小写的,即SELECT与select是一个含义。
2. 构建诗人关系网络
构建诗人关系网络运算量较大,花费时间也较长,为了避免使用者的疑虑,我们使用了图11所示的文本进度条来表示执行进度。
2.1 诗/诗人的筛选准备
我们先定义了两个类型,Poem和Poet。
1 | class Poem: |
这两个类型的对象分别存储一首诗和一个诗人。其数据成员/属性的名称与数据库中的字段名称基本相同。需要说明的是,Poet类中有一个名称为altNames的属性,其类型为列表,该列表预期用于存放该诗人的全部别名。
loadPoemsPoets()函数负责对唐诗和诗人进行筛选。只有当一首唐诗的诗人可以明确地在poet表,即中国历代人物专辑中定位到确定的个体时,该唐诗和诗人才会被纳入统计范围。在筛选过程中,代码通过printProgressBar()函数不断刷新进度条。
1 | #ConstuctNetwork.py |
【SQL语句说明】
◆ SELECT id, title, author, content FROM poem——表示从poem表查询全部记录,包括id、title、 author和 content共4个字段。
【数据结构说明】
① poems: 列表,元素类型为Poem。
② poets: 字典,键为诗人的姓名,值类型为Poet对象。
③ r: 由dbCursor.fetchall()返回的数据集,类型为列表,poem表里的每一行对应r列表中的一个元素,类型为元组。每个元组包含4个元素,分别是id、 title、 author、content。
1 | t = poets.get(m.author,None) or getPoetByName(m.author,dbCon) |
这段代码首先从poem表查询所有唐诗,然后遍历。对于每首唐诗,先尝试从poets字典中按姓名获取作者,如果没有找到,则通过getPoetByName()函数从数据库poet表中查询。只有当唐诗的作者能够从poet表中“准确”地定位并找到时,才将这首唐诗以及作者加入poems列表和poets字典。
1 | #Utils.py |
getPoetByName()函数根据诗人姓名从poet表中查询创建并返回一个Poet对象,如果找不到或者无法准确定位,则返回None。请注意,为了避免多次建立数据库连接,dbCon参数从调用者那里获取一个现成的连接。
【SQL语句说明】
◆ SELECT id, name, birthyear, deathyear FROM poet WHERE name like ‘%杜牧%’——表示从poet表中查询姓名中包含“杜牧”的诗人,返回id、name、birthyear、deathyear共4个字段。%在SQL语言里被用作通配符,它代表0到多个任意字符。这里之所以采用模糊匹配,是因为在poet表,即《中国历代人物传记》当中,姓名后面可能有一些备注,如李隆基,在poet表中其name字段内容为: 李隆基(唐玄宗)。
同理,dbCursor.fetchall()返回的candidates为一个列表,列表元素为元组,元组内包含id、name、birthyear和deathyear。由于重名的原因,candidates列表中的诗人可能不止一位。此时,考察生卒年,如果生卒年明确且与唐朝有交集,直接生成Poet对象,并获取其别名列表后返回。如果生卒年中只有一项是明确的,且与唐朝有交集,则加入candidatesFiltered候选列表。预处理完成后,如果候选列表当中有2位以上的候选人,这说明具体是哪位诗人存在歧义,放弃返回None。如果只有一位候选人,则创建Poet对象,并通过getAltNamesById()函数获取其altNames - 别名列表,然后返回。
1 | #Utils.py |
【SQL语句说明】
◆ SELECT name FROM altname WHERE id = 3915——表示从别名表altname中查询id为3915的诗人的全部别名。
注意
◆ 虽然dbCursor.fecthall()的返回数据集中仅包含一个字段(name),返回数据集r中的元素仍为元组,for (x,) in r中进行了元组展开。在《中国历代人物传记》中,很多人物的别名只有一个单字,如德,使用单字别名进行引用匹配,显然会带来很多错误。故上述代码仅返回那些至少两个字以上的别名。
2.2 引用关系匹配
在筛选获得了唐诗列表poems以及诗人字典poets之后,逐一对每首唐诗进匹配处理,发现诗人之间的引用关系。
1 | #ConstructNetwork.py |
【数据结构】
◆ references:列表,列表元素为元组,每个元组形如(authorid, refid, poemid),其中,authorid指唐诗的作者在poet表中的id,refid指被引用的诗人在poet表中的id,poemid是唐诗在poem表中的id。
代码遍历全部的唐诗,对于每一首唐诗,遍历全部诗人,如果唐诗的标题或者全文中直接提到了诗人的名字,如《黄鹤楼送孟浩然之广陵》,这被认为引用关系成立,则将引用关系加入references列表。如果本名没有被引用,则逐一考察诗人的别名,如果别名在标题或者全文中被引用(如刘禹锡作《叹水別白二十二》,白二十二是白居易的别名),也加入references列表。上述三重循环结束以后, references列表中以元组形式包含了全部的引用关系。
【SQL语句说明】
① DELETE FROM reference——表示删除reference表中的全部记录(清除旧数据)。
② INSERT INTO reference VALUES (?,?,?)——表示在表格reference表中插入一行记录。dbCursor.executemany()函数会将参数references列表展开,将其中的元组内的元素逐一替换上述SQL语句中的?号,形成完整的多条SQL语句并逐一执行。最后提交给数据库的完整SQL语句形如:INSERT INTO reference VALUES (13060,15610,85)。
至此,诗人之间的引用关系被全部存入了referencce表。这个表中记录了诗人A在诗B中引用了诗人C的信息。
3. 查询诗人关系网络
下述程序要求操作者输入两个诗人的姓名,如果两个诗人都存在,则打印两个诗人的信息以及他们之间相互引用的诗。
1 | #QueryNetwork.py |
诗人信息的打印通过Poet类的print()成员函数来完成;诗的打印通过Poem类的print()函数来完成。getReferencePoems()函数查询并返回诗人A引用诗人B的全部诗词的列表。其代码如下:
1 | #QueryNetwork.py |
【SQL语句说明】
◆ SELECT id, title, author, content FROM poem WHERE id IN ( SELECT poemid FROM reference WHERE authorid = ? and refid = ?)——括号内的SQL代码按照指定的作者id及被引用人id从reference表中查询诗的id,字段名为poemid,其结果应该是一个数据集,数据集中包括相关唐诗的id。括号外部的SQL语句,借助括号内部SQL的返回数据集,从poem表中查出全部的相关唐诗。
下面是QueryNetwork.py的示例执行结果:
1 | 请输第一位诗人的姓名:(q退出)李白 |
看得出,李白经常提到孟浩然,“吾爱孟夫子,风流天下闻”。而孟浩然则很少提到李白。笔者这里用到的是“很少”这个词,因为笔者相信,与李白一样,孟浩然的很多作品肯定散佚在历史的长河中了,存世的只是很少的一部分。
4. 可视化诗人关系网络
QueryNetwork.py只能查询两个诗人之间的相互引用关系。VisualizeNetwork.py则可以将全唐最重要诗人之间的关系网络直观地显示出来。
4.1 数据准备
1 | #VisualizeNetwork.py |
4万多首全唐诗涉及900多位诗人,如果把全部的引用关系都画在图里,纷繁错杂。为了突出显示最主要的诗人,我们只选择引用次数最多的前num对诗人。这里用到的SQL语句如下:
1 | SELECT authorid, refid, count(*) as cnt |
上述SQL代码将reference表中的数据按authorid、refid分组(GROUP BY),并统计每一组的总条数;返回前50行数据(LIMIT 50);返回的数据按条数降序排序(ORDER BY cnt DESC)。
将上述代码在SQLITE EXPLORER的Query中执行,得到图12所示的执行结果。
可以看到,有一位编号为33 753的诗人引用了另一位编号为3 702的诗人多达199次,反过来诗人3 702引用诗人33 753共120次。稍后我们将看到这两位好朋友分别是谁。getPoetNameById()函数通过诗人的id号查询并返回其姓名。上述数据准备函数将返回一个列表,其形式为:[(’李白’,’杜甫’,3),(’孟浩然’,’杜甫’,12),…],列表中的每一个元组表示一个引用关系,如上述列表的第一个元组表示李白引用了杜甫3次。
4.2 HTML存档
准备好引用关系列表后,saveToHtml(r)函数将引用关系列表存档为网页/HTML以及文本文件/TXT。
1 | #VisualizeNetwork.py |
可以看到,表示诗人关系网络的HTML网页文件由文件头(sHtmlHead)、文件尾(sHtmlTail)、节点(sNodes)、连接(sLinks)共4个部分构成。其中,文件头是从文件html/htmlhead.txt读进来的,文件尾则是从文件html/htmltail.txt读出来的。在上述HTML文件中,使用了JavaScript来渲染诗人关系网络图,并使用到了一些第三方的JavaScript库。这些部分超出了本书的范围。此外,上述函数还顺手将诗人关系网络存到了文本文件,从如下的执行结果中,我们可以看到那个引用199次的冠军是哪两位了。
1 | 诗人 被引用人 次数 |
为了便于查看,笔者分别保存了引用关系前50、100、200、500行的诗人关系网络。其实现代码如下:
1 | #VisualizeNetwork.py |
5. 小结
保存出来的诗人关系网络的网页可以用浏览器查看,方法是:直接找到对应文件夹(html子目录),然后双击相应文件即可。如图13所示。读者可以用鼠标按住诗人的姓名,然后拖动观察。
在上述网络中,诗人与诗人之间的连线的粗细表示引用的次数。可以看见,白居易与元稹,白居易与刘禹锡过从甚密。陆龟蒙与皮日休两位相互疯狂引用,但与其他诗人联络却相对较少。读者如果拖一下白居易和刘禹锡,可以看到他们两位以及元稹,几乎在中唐诗人中处于核心地位,属于带头大哥级别。熟悉唐诗的朋友可能知道,李杜时期的盛唐诗人以浪漫主义为主,而白居易、元稹为代表的中唐诗人逐渐转向现实主义。