关键词抽取与自动文摘
在自然语言处理中对于关键词抽取与自动文摘这两个主题,有着多种多样的方式去解决它们,这里将介绍一种叫做TextRank的方法,就可以解决这两个问题。我将结合具体的代码,试图将算法解释地更加清楚些。
论文『TextRank: Bringing Order into Texts』中首次提出了TextRank方法,如果想全面了解下这个方法,还是仔细看下这篇论文。
当你一看到TextRank这个名字的时候,你是否会觉得很熟悉,它是否有让你联想到Google的PageRank。其实,该方法就是基于PageRank而来的,只是将page替换成word、sentence以完成关键词抽取与自动文摘任务。当然其中的一些计算准则是以文本为标准的。
如果你还不熟悉PageRank的话,还是去网上找一篇博客来看看,其实是不难的。
关键词抽取中的TextRank
之前我们也提到了,就是将PageRank模型中的page替换成word。因为是用于文本中,所以与用于网页中是不一样的。改动的地方主要有:
PageRank中因为有链接的存在,所以是将存在链接的两个网页相连。而在文本处理中,一个词的出现,与其前后词之间是有联系的。在TextRank中存在一个窗口的概念,将一个词与窗口内的所有词进行连接。窗口值的设置也是比较讲究的。实验表明,如果窗口值设置的过大,不仅带来计算时间上的提高(因为图中的边数增加),而且会造成模型性能的下降。这里模型的性能是使用具体的任务去衡量的,关键词抽取是作为具体任务的前驱。
因为链接是具有指向性的,所以PageRank模型构造的是一个有向图。而在文本中,词与词之间的联系是很难确定方向的,所以既可以构造一个有向图也可以构造一个无向图。实验表明,有向图和无向图在收敛性上是没有差别的,就是它们都会收敛到一个固定值上。至于初始值的设置,在PageRank中就已经证明了,最终的结果与初值的设定无关,只与迭代的次数有关。只要迭代的次数足够,就能收敛到一个固定值。
在PageRank中,会将爬虫所爬到的所有网页都加入到模型中去,至于死链的去除,这都是后话。但在TextRank中,不会将文本中出现的所有词都加入到图模型中。因为不是所有的词都是有其实际意义的,例如说介词。所以在构建图模型时,会利用一定的语法规则过滤掉一些词,以减小图的规模。这里的过滤规则一般来说是基于词性的,所以在之前进行分词是还需要将词的词性给标记出来。实验表明,最佳的选择是保留名词和形容词。
PageRank模型中,连接的关系一般只会发生一次,很少会有一个网页中存在两个指向同一个网页的链接。但在文本中却不同,有一些词是固定的搭配,它们会在一起出现多词。所以TextRank所构造的图应该是一个赋权图。这里的权值应该是两者共同出现的次数。相应的每个结点权值的更新公式应该更改为:
其中d表示衰减系数,这个在PageRank模型中就已经有了的, WS(Vi) 表示结点 Vi 的权值。在无向图中, In(Vi)、Out(Vi) 均表示与结点 Vi 相连的结点;在有向图中,则分别表示结点的入边和出边。
在构造完图之后,就与PageRank一样,使用迭代的算法计算每一个结点的权值。根据你想获得的关键词的数目,输出权值最高的那些结点所对应的词。
jieba中的关键词抽取
下面我们以开源中文分词库jieba为例,看看其中关键词抽取的实现。项目的主页是https://github.com/fxsjy/jieba
,这里列出的是python的版本,还有其他很多语言的版本可供使用。
# 用于表示文本的无向带权图
class UndirectWeightedGraph:
d = 0.85 # 衰减系数
def __init__(self):
# 用一条边的起始结点作为关键字的字典来表示整个图的信息
self.graph = defaultdict(list)
def addEdge(self, start, end, weight):
# use a tuple (start, end, weight) instead of a Edge object
self.graph[start].append((start, end, weight))
self.graph[end].append((end, start, weight))
def rank(self):
ws = defaultdict(float) # 表示每一个结点的权重
outSum = defaultdict(float) # 结点所关联边的权值之和
wsdef = 1.0 / (len(self.graph) or 1.0) # 为每个结点初始化一个权值
for n, out in self.graph.items():
ws[n] = wsdef
outSum[n] = sum((e[2] for e in out), 0.0)
# this line for build stable iteration
sorted_keys = sorted(self.graph.keys())
for x in xrange(10): # 10 iters,textrank值的计算只进行10次循环
# 进行textrank值的更新
for n in sorted_keys:
s = 0
for e in self.graph[n]:
s += e[2] / outSum[e[1]] * ws[e[1]]
ws[n] = (1 - self.d) + self.d * s
(min_rank, max_rank) = (sys.float_info[0], sys.float_info[3]) # 系统设定的最大和最小值
for w in itervalues(ws): # 返回值的迭代器
if w < min_rank:
min_rank = w
if w > max_rank:
max_rank = w
# 将权值进行归一化
for n, w in ws.items():
# to unify the weights, don't *100.
ws[n] = (w - min_rank / 10.0) / (max_rank - min_rank / 10.0)
return ws
class TextRank(KeywordExtractor):
def __init__(self):
self.tokenizer = self.postokenizer = jieba.posseg.dt # 对输入文本进行分词的方法
self.stop_words = self.STOP_WORDS.copy() # 停用词列表
self.pos_filt = frozenset(('ns', 'n', 'vn', 'v')) # 词性的筛选集
self.span = 5 # 表示词与词之间有联系的窗口的大小为5
def pairfilter(self, wp):
# 确保该词的词性是我们所需要的,且这个词包含两个字以上,且都是小写,并且没有出现在停用词列表中
return (wp.flag in self.pos_filt and len(wp.word.strip()) >= 2
and wp.word.lower() not in self.stop_words)
def textrank(self, sentence, topK=20, withWeight=False, allowPOS=('ns', 'n', 'vn', 'v'), withFlag=False):
"""
Extract keywords from sentence using TextRank algorithm.
Parameter:
- topK: return how many top keywords. `None` for all possible words.
- withWeight: if True, return a list of (word, weight);
if False, return a list of words.
- allowPOS: the allowed POS list eg. ['ns', 'n', 'vn', 'v'].
if the POS of w is not in this list, it will be filtered.
- withFlag: if True, return a list of pair(word, weight) like posseg.cut
if False, return a list of words
"""
self.pos_filt = frozenset(allowPOS)
g = UndirectWeightedGraph()
cm = defaultdict(int)
words = tuple(self.tokenizer.cut(sentence)) # 首先必须要对文本进行分词
for i, wp in enumerate(words): # i和wp分别代表words的下标与其对应的元素,元素内容包括词以及其对应的词性
if self.pairfilter(wp):
for j in xrange(i + 1, i + self.span):
if j >= len(words):
break
if not self.pairfilter(words[j]):
continue
if allowPOS and withFlag:
cm[(wp, words[j])] += 1
else:
cm[(wp.word, words[j].word)] += 1 # 如果两个词出现在同一个窗口内,则在两者间加上一条边
for terms, w in cm.items():
g.addEdge(terms[0], terms[1], w) # 将共同出现的次数作为边的权值,表示边的关键字用词而非词的编号
nodes_rank = g.rank()
if withWeight:
tags = sorted(nodes_rank.items(), key=itemgetter(1), reverse=True)
else:
tags = sorted(nodes_rank, key=nodes_rank.__getitem__, reverse=True)
if topK:
return tags[:topK]
else:
return tags
自动文摘中的TextRank
在关键词抽取任务中,图模型中的结点是词,在自动文摘中结点表示的就是句子。模型的构建方式自然也要发生一些改变,主要有以下两点:
首先,窗口模型在这个任务中并不适用,因为前后句子间的关系不像是词间的关系那么紧密。
其次,图中的权值使用的是句子间的相似度,具体是使用哪一种衡量标准,还是要看实现吧。
模型构建好之后,还是利用迭代的方式求出每一个句子的权值。根据你所希望文摘的长度,输出权值最高的句子。我觉得这个方法所存在的最为重要的一个问题是:因为只是从文章中抽取出几个句子作为代表,这些句子之间逻辑上可能并没有联系,所以得到的文摘看上去会有一些别扭。当然也有方法是根据文摘的内容自动地生成摘要,那样的话,逻辑上会更为通顺一些。当然方法的难度上肯定会更大,这个就不在讨论范围之内了。
snownlp中的自动文摘
接下来,我们以开源库snownlp为例,看一下其中的自动文摘算法实现。算法的主页是https://github.com/isnowfy/snownlp
,其主要的任务与jieba还是非常类似的。
class SnowNLP(object):
@property
def sentences(self): # 将一篇文章按句划分,实际上是细分到了逗号的
return normal.get_sentences(self.doc)
def summary(self, limit=5):
doc = []
sents = self.sentences
for sent in sents: # 这里对整个句子进行了分词,是为了方便计算句子间的相似度。一个句子的分词结果是以一个list方式存储的,并没有破坏句子的完整性
words = seg.seg(sent)
words = normal.filter_stop(words)
doc.append(words)
rank = textrank.TextRank(doc)
rank.solve()
ret = []
for index in rank.top_index(limit):
ret.append(sents[index]) # 这里应该返回整个句子,而不是句子的分词结果
return ret
class TextRank(object):
def __init__(self, docs):
self.docs = docs
self.bm25 = BM25(docs)
self.D = len(docs)
self.d = 0.85
self.weight = [] # 存储一篇文档中每个句子间的相似度,作为连接边的权值
self.weight_sum = [] # 某一个句子与其他句子之间的相似度之和, 作为该点的入度(出度)
self.vertex = [] # 每一个句子的textrank值
self.max_iter = 200
self.min_diff = 0.001
self.top = []
def solve(self):
for cnt, doc in enumerate(self.docs):
scores = self.bm25.simall(doc) # 使用BM25作为衡量句子间相似度的标准
self.weight.append(scores)
self.weight_sum.append(sum(scores)-scores[cnt])
self.vertex.append(1.0)
for _ in range(self.max_iter):
m = []
max_diff = 0
for i in range(self.D):
m.append(1-self.d)
for j in range(self.D):
if j == i or self.weight_sum[j] == 0: # 判断是否等于0是为了避免除0错误
continue
# 进行textrank值的更新
m[-1] += (self.d*self.weight[j][i]
/ self.weight_sum[j]*self.vertex[j])
if abs(m[-1] - self.vertex[i]) > max_diff: # 记录最大的改变量
max_diff = abs(m[-1] - self.vertex[i])
self.vertex = m
if max_diff <= self.min_diff: # 如果textrank值没有明显的变化,则跳出循环
break
self.top = list(enumerate(self.vertex))
self.top = sorted(self.top, key=lambda x: x[1], reverse=True) # 按照textrank的值进行排序
def top_index(self, limit):
return list(map(lambda x: x[0], self.top))[:limit]
def top(self, limit):
return list(map(lambda x: self.docs[x[0]], self.top))
这里使用了使用了BM25作为衡量两个句子间相似度的标准,关于BM25的含义,可参考wiki:https://en.wikipedia.org/wiki/Okapi_BM25
。
class BM25(object):
def __init__(self, docs):
self.D = len(docs)
self.avgdl = sum([len(doc)+0.0 for doc in docs]) / self.D # 计算文档的平均长度
self.docs = docs
self.f = [] # 统计每一个词出现的次数
self.df = {} # 统计一个词在多少篇文档中出现过
self.idf = {}
self.k1 = 1.5
self.b = 0.75
self.init()
def init(self):
for doc in self.docs: # 在传入文档时,每一个文档都已经是分好词了的,每一篇文档就是一个list
tmp = {}
for word in doc:
if not word in tmp:
tmp[word] = 0
tmp[word] += 1
self.f.append(tmp)
for k, v in tmp.items():
if k not in self.df:
self.df[k] = 0
self.df[k] += 1
for k, v in self.df.items():
self.idf[k] = math.log(self.D-v+0.5)-math.log(v+0.5) # 计算逆文档频率,这个公式在wiki中有介绍,并不是一般我们计算逆文档频率的公式
def sim(self, doc, index):
score = 0
for word in doc:
if word not in self.f[index]:
continue
d = len(self.docs[index])
# 计算输入与该篇文档的相似度
score += (self.idf[word]*self.f[index][word]*(self.k1+1)
/ (self.f[index][word]+self.k1*(1-self.b+self.b*d
/ self.avgdl)))
return score
def simall(self, doc): # 将输入看做是查询语句,计算相似度
scores = [] # 计算文档之间两两相似度
for index in range(self.D):
score = self.sim(doc, index)
scores.append(score)
return scores