基于 TextRank 实现抽取式文本摘要

TextRank 是一种将 PageRank 算法应用于自然语言处理领域的算法实现。PageRank 算法将网页作为图的顶点,网页之间的链接作为边。TextRank 则是将词或者句子作为定点,当以词作为定点时,单词的共现作为边。当以句子作为定点时,以句子的相似度作为边。

http://mengbaoliang.cn/?p=23962

使用 TextRank 算法进行文本摘要关键句子抽取的步骤如下:

  1. 将输入文档切分成子句
  2. 以子句为顶点、相似度为边构建图结构
  3. 使用 PageRank 公式计算每个句子的分数
  4. 返回分数较高的 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个。']
未经允许不得转载:一亩三分地 » 基于 TextRank 实现抽取式文本摘要
评论 (0)

2 + 2 =