TextRank 是一种将 PageRank 算法应用于自然语言处理领域的算法实现。PageRank 算法将网页作为图的顶点,网页之间的链接作为边。TextRank 则是将词或者句子作为定点,当以词作为定点时,单词的共现作为边。当以句子作为定点时,以句子的相似度作为边。
使用 TextRank 算法进行文本摘要关键句子抽取的步骤如下:
- 将输入文档切分成子句
- 以子句为顶点、相似度为边构建图结构
- 使用 PageRank 公式计算每个句子的分数
- 返回分数较高的 topK 作为摘要
我们接下来,要对下面的输入文档进行关键句子抽取:
尼克劳斯吃惊伍兹至今尚未反弹 滑坡迟早会结束。 新浪体育讯 北京时间3月3日,根据美联社报道,尼克劳斯怀疑他所保持的大满贯赛冠军纪录会保持下去,他认为伍兹结束滑坡只是迟早的事情,尽管他很惊讶伍兹迄今为止还没有反弹。。 自从2009年11月份深陷性丑闻以来,伍兹还没有赢得一场比赛。他的大满贯赛头衔停滞在14个上,与尼克劳斯的纪录还差4个。。 “我仍旧认为他将打破我的纪录。”尼克劳斯星期三在打本田精英赛职业/业余配对赛之前说,“我很惊讶到现在他还没有反弹回来。他的工作态度一直很棒。对自己想做的事情他总是那么果断。”。 尼克劳斯表示自从去年6月份以来他还没有同伍兹面谈过,而那一次的对话也是短暂的。“他也许脱轨了,不过我觉得他真的是一个守规矩的孩子。”尼克劳斯说,“他是不是走了一些歧路……?是的。但我们都完美无缺吗?不。”。 伍兹没有参加本周在PGA全国度假村举行的本田精英赛,事实上自从1993年开始,他就没有参加过这场赛事。上个星期,他在WGC-埃森哲世界比洞赛第一轮遭遇淘汰。今年的三场比赛,他没有一场进入前20名。。 尼克劳斯回忆起1979年到1980年他所经历的最坏的滑坡。在美国公开赛之前,他遭遇了一场淘汰,然而在巴特斯罗高尔夫俱乐部(Baltusrol Golf Club),他重新发现了取胜的秘诀。“我在第一轮打出了63杆,我在最后一个洞错过了一个很短的推杆,未能打出62杆。”尼克劳斯回忆说,“突然之间,我说:‘嗨,你知道吗,也许这是我走上正轨的开始。’突然之间,你的心态完全转变了。你只是需要不断努力,继续做下去。有些东西会突然出现。我认为老虎身上也会发生这种事情。”。 (小风)。。
导入的包如下:
import re import numpy as np from pyhanlp import JClass from string import punctuation import jieba jieba.setLogLevel(0) import math
1. 切分子句
一般单独的一个句子都是以问号、句号、感叹号等等结尾,我们就以这些特殊的字符作为分隔符来切分句子。但是由于切分之后的子句,开始可能还有一些特殊符号,我们将其删除,例如:我们使用 clean_sentence 函数来去除每个子句开头的特殊符号。另外,太短的句子我们也不要。实现代码如下:
# 将输入拆分成子句 normalizer = JClass('com.hankcs.hanlp.dictionary.other.CharTable') def document_to_sentences(document): # 标准化输入的句子 document = normalizer.convert(document) # 将文档切分成句子 separator = ['?', '!', ';', '?', '!', '。', ';', '……', '…', '\n'] segments = [] # 处理一下每个子句 def clean_sentence(sentence): # 去掉开始的特殊字符 sentence = re.sub(r'^[^a-zA-Z0-9\u4e00-\u9fa5]+', '', sentence) return sentence for position in re.finditer('.*?[' + '|'.join(separator) + ']', document): sub_start = position.start() sub_end = position.end() sub_sen = document[sub_start: sub_end] sub_sen = clean_sentence(sub_sen) if len(sub_sen) < 6: continue segments.append(sub_sen) return segments
2. 构建句子图结构
句子图结构是以句子为顶点,相似度为边。这里句子的相似度我们可以使用基于词共现的相似度计算方法。当然,我们可以使用 TF-IDF、word2vec 等方法,将句子转换为对应的向量,并使用余弦相似度的值来作为图的边。基于共现的句子相似度实现函数如下(输入的是两个句子分词之后的列表):
def calculate_distance(sentence1_words, sentence2_words): # 单词去重 unique_words = list(set(sentence1_words + sentence2_words)) # 统计句子中出现的词的个数 sentence1 = [float(sentence1_words.count(word)) for word in unique_words] sentence2 = [float(sentence2_words.count(word)) for word in unique_words] # 计算词一同出现的次数 sentence = [sentence1[index] * sentence2[index] for index in range(len(unique_words))] word_co_occ_num = sum([1 for number in sentence if number > 0.]) if abs(word_co_occ_num) <= 1e-12: return 0. denominator = math.log(float(len(sentence1_words))) + math.log(float(len(sentence2_words))) if abs(denominator) < 1e-12: return 0. return word_co_occ_num / denominator
上面在计算过程中,用相同的词出现的次数,除以两个句子长度的 log 和,这是为了减小句子长度为相似度分数计算的影响。例如:
句子1: "This is a long sentence with many words." 句子2: "This is a short sentence."
这两个句子有4个单词共同出现(This, is, a, sentence),但是句子1长度更长。如果不考虑句子长度,这两个句子应该有相似的分数。然而,由于句子1长度更长,计算的相似度分数可能会偏低。
为了解决这个问题,我们除以两个句子长度的 log 值的和。假设句子1长度为8,句子2长度为4,那么相似度的分母为log(8)+log(4)=3.58。如果我们将共同出现的单词数4除以3.58,得到的相似度分数为1.11。这个分数表明两个句子非常相似,这是由于它们共同出现了相当数量的单词,而且长度的差异被消除了。
构建图的函数代码如下:
# 构建以句子为顶点,相似度为边的图 def build_sentence_graph(segments): # 计算句子的数量 sentence_number = len(segments) # 初始化图顶点(句子) sentence_graph = np.zeros(shape=(sentence_number, sentence_number)) # 初始化图的边(相似度) for i, segment_i in enumerate(segments): segment_i = jieba.lcut(segment_i) for j, segment_j in enumerate(segments): segment_j = jieba.lcut(segment_j) distance = calculate_distance(segment_i, segment_j) sentence_graph[i, j] = distance # 归一化图矩阵 sentence_graph_norm = sentence_graph / np.sum(sentence_graph, axis=0).reshape(1, -1) return sentence_graph_norm
构建的图中,每一行都表示第 N 个句子和其他句子的相似度。最后,我们以列为单位,将每一列进行归一化。
3. 计算句子分数
这一步就是根据生成的图,根据 PageRank 公式迭代计算每个句子的分数。
# 计算每个句子的分数 def calculate_sentences_score(sentence_graph_norm): # 获得句子的个数 sentence_number = sentence_graph_norm.shape[0] # 初始化句子分数(1/句子数量) init_scores = np.array([1 / sentence_number] * sentence_number).reshape(-1, 1) # 算法阻尼系数(句子保留分数) d = 0.85 # 迭代求解句子分数 text_rank = init_scores for _ in range(100): text_rank = (1 - d) * init_scores + d * (sentence_graph_norm @ text_rank) text_rank = text_rank.reshape(-1) # 按照句子分数进行排序 sorted_sentences_index = np.argsort(-text_rank) sorted_sentences_score = [text_rank[index] for index in sorted_sentences_index] return sorted_sentences_index, sorted_sentences_score
4. 返回关键句子
这一步其实就是将前面几个步骤组合到一个函数中,实现关键句子抽取。这里,我们默认抽取原文档句子数量的 20%,当然也可以指定抽取的数量。
def extract_key_sentences(document, k=None): # 切分字句 segments = document_to_sentences(document) # 构建句子图 graph = build_sentence_graph(segments) # 计算分数 sorted_sentences_index, sorted_sentences_score = calculate_sentences_score(graph) # 抽取数量(默认抽取20%的子句) if k is None: k = max(int(len(sorted_sentences_index) * 0.2), 1) # 提取句子 select_index = sorted_sentences_index[:k] key_sentence = [segments[index] for index in select_index] return key_sentence
最终输出的关键句子为(该句子并未排序,输出时,需要按照句子在原文档中的顺序输出):
['新浪体育讯 北京时间3月3日,根据美联社报道,尼克劳斯怀疑他所保持的大满贯赛冠军纪录会保持下去,他认为伍兹结束滑坡只是迟早的事情,尽管他很惊讶伍兹迄今为止还没有反弹。', '尼克劳斯星期三在打本田精英赛职业/业余配对赛之前说,"我很惊讶到现在他还没有反弹回来。', '尼克劳斯表示自从去年6月份以来他还没有同伍兹面谈过,而那一次的对话也是短暂的。', '他的大满贯赛头衔停滞在14个上,与尼克劳斯的纪录还差4个。']