python——NLP关键词提取

关键词提取顾名思义就是将一个文档中的内容用几个关键词描述出来,这样这几个关键词就可以提供这个文档的大部分信息,从而提高信息获取效率。

关键词提取方法同样分为有监督和无监督两类,有监督的方法比如构造一个关键词表,然后计算文档和每个次的匹配程度用类似打标签的方法来进行关键词提取。这种方法的精度比较高,但是其问题在于需要大量的有标注数据,人工成本过高,而且由于现在信息量的快速增加,一个固定的词表很难支持时刻增加的文档信息,因此维护这个词表也需要很大的成本,而无监督的方法成本则相对较低,更受大家的青睐。

TF-IDF算法:

TF-IDF算法(词频-逆文档频次算法)是基于统计的计算方法,常用于评估在一个文档集中一个词对某份文档的重要程度。直观来看,一个词对文档越重要,这个词就越可能是文档的关键词。

而什么叫做一个词对文档的重要程度呢?人们用两种方法来进行度量,一个是TF值,即这个词在文档中出现的频率,不难想见一个词在文档中出现的频率越高,这个词在这篇文档中就很可能越重要。

但是这样度量的一个缺点在于很多常用词会出现在大量的文档中,它们并不能反映这篇文档的关键信息,比如“你”“我”“他”“什么”这样的词就算出现频率再高也不提供有效信息,因此只考虑TF值是不够的。

那么我们还有一个度量标准就是IDF值,用来衡量一个词在一个文档集合中的多少篇文档中出现了,不难想见只在某一篇文档中出现了而别的文档中都没出现的词肯定不会是什么常用词,很有可能是反映这篇文档信息的关键词。

但是单独使用这个标准也是有问题的——就算有一个生僻词“魑魅魍魉”只在一篇文档中出现了,这也不提供什么有效信息,只是因为这个词太生僻了所以出现次数少而不是因为这个词与这个文档密切相关所以出现次数少。

所以我们综合考察这两个特征,我们认为在一篇文档中很重要的词、反映这篇文档主要信息的词应该是在这篇文档中大量出现,而在其他文档中出现不多甚至不出现的词,而综合这两种考量我们就得到了TF-IDF值。

于是我们定义一个词语$i$在文档$j$中的TF值为:

$tf_{ij}=\dfrac{n_{ij}}{\sum_{k}n_{kj}}$

即词语$i$在文档$j$的所有词中出现的频率。

而一个词语$i$在所有文档集中的IDF值为:

$idf_{i}=\log (\dfrac{|D|}{1+|D_{i}|})$

其中$|D|$为文档总数,$|D_{i}|$为有词语$i$出现的文档数,+1是拉普拉斯平滑用来避免一个词语在任何文档中都不出现的情况发生,以增加算法的健壮性。

这样结合一些研究经验,我们就得到了$TF-IDF$的best-practice:对一个词语$i$,其出现在文档$j$中的$tf-idf$值为:

$tf-idf_{ij}=tf_{ij}*idf_{i}=\dfrac{n_{ij}}{\sum_{k}n_{kj}}\log (\dfrac{|D|}{1+|D_{i}|})$

那么现在我们可以认为在文档$j$中,$tf-idf_{ij}$越大的词语$i$对文档$j$而言越重要,越可能是关键词。

TextRank算法:

TextRank算法是一种独特的算法,很多关键词提取算法都需要本文档之外的语料库,比如TF-IDF算法需要一个文档集中的其他文档作为对比来计算出IDF值,但是TextRank算法只基于当前文档就可以提取出关键词。

TextRank算法的思想来源于Google的PageRank思想,PageRank算法的基本思想是:

1.如果一个网页被越多其他的网页链接,那么这个网页越重要(大家都跟你链接说明你当然很重要)

2.如果链接到这个网页的网页权重越高,那么这个网页越重要(即重要的网页链接的网页也是重要的网页)

于是对于一个网页$V_{i}$,我们设$In(V_{i})$表示链接到这个网页的网页集合,而$Out(V_{i})$表示这个网页能链接到的网页的集合,设一个网页的重要性为$S(V_{i})$,我们有迭代:

$S(V_{i})=\sum_{j \in In(V_{i})}\dfrac{S(V_{j})}{|Out(V_{j})|}$

注意后面除掉的$Out(V_{j})$因为我们认为一个网页把自己重要性的贡献均摊给每个它链接到的网页

而为了能进行这样的迭代,我们初始将所有网页的重要性设为$1$,然后开始迭代直到收敛为止,如果不收敛也可以设定最大迭代次数。

但是有一些网页没有入链和出链,这样的网页得分可能会变成0,为了避免这样的状况我们要加入一个阻尼系数$d$,得到公式:

$S(V_{i})=(1-d)+d\sum_{j \in In(V_{i})}\dfrac{S(V_{j})}{|Out(V_{j})|}$

而我们把PageRank的思想应用到TextRank上,我们就要解决一个问题——什么叫两个词之间有链接呢?

不妨认为如果两个词在文档中出现的距离比较近,这两个词就有链接关系,因此我们引入了一个滑动窗口的思想:对于一个已经分好词的文档,我们用一个定长的滑动窗口从前向后扫描,一个窗口中的每个词我们认为互相链接。

比如对于一句话:“今天/大家/要/一起/努力/学习”,我们用一个长为3的滑动窗口滑动,那么得到的结果就是:【今天,大家,要】,【大家,要,一起】,...,【一起,努力,学习】,每个窗口中的所有词互相链接,然后还是套用上面的公式:

$S(V_{i})=(1-d)+d\sum_{j \in In(V_{i})}\dfrac{S(V_{j})}{|Out(V_{j})|}$

选出重要程度比较高的词语作为关键词即可。

主题模型

 一般来说这两种算法已经足够满足一般的关键词提取的需求了,但是在实际应用中还会出现这样的情况:比如我们一篇文章的主要内容是介绍动物的捕猎技巧,第一段讲了狮子,第二段讲了老虎...那么在这篇文档中,关键词“动物”其实出现的并不多,甚至可能没有出现,而具体的动物种类(比如“狮子”“老虎”等)则会大量出现,但我们知道这篇文章的内容应该是“动物”“捕猎”而不是“狮子”“老虎”....“捕猎”,也就是说我们希望根据“狮子”“老虎”这些具体内容提取出“动物”这个主题

为此,我们引入主题模型,所谓主题模型,也就是说我们在词和文档之间插入一个层次,我们并不认为词与文档有直接的关联,相对地,我们认为一篇文档中涉及到了很多主题,而每个主题下会有若干个词语,也就是说我们在词和文档之间插入了主题这个层次。

对于这个模型,我们始终有一个核心的公式:

$P(w_{i}|d_{j})=\sum_{k=1}^{K}P(w_{i}|t_{k})P(t_{k}|d_{j})$

其中$P(w_{i}|d_{j})$表示词语$w_{i}$在文档$d_{j}$中出现的概率,类似地定义$P(w_{i}|t_{k})$表示词语$w_{i}$在主题$t_{k}$中出现的概率,$P(t_{k}|d_{j})$表示主题$t_{k}$在文档$d_{j}$中出现的概率,而$K$为主题的个数。

当然,后面两个“概率”并不是那么严格意义下的概率,其可以被理解成一种相关性——表示某个词语(主题)与某个主题(文档)的关联程度,这个“概率”越大说明关系越密切。

那么如果我们给定文档的集合,那我们可以容易地统计出$P(w_{i}|d_{j})$,那么如果我们能通过一些方法计算出$P(w_{i}|t_{k})$和$P(t_{k}|d_{j})$,我们就能得到主题的词分布信息和文档的主题分布信息了!

LSA/LSI算法:

LSA(潜在语义分析)和LSI(潜在语义索引)通常被认为是同一种算法,只是应用场景略有不同,该算法主要步骤如下:

1.使用BOW模型将每个文档表示为向量

2.将所有文档词向量拼接起来构成词-文档矩阵

3.对词-文档矩阵进行奇异值分解(SVD)操作,

4.根据SVD的结果,将词-文档矩阵映射到一个更低维度的近似SVD结果,每个词和文档都可以表示为$k$个主题构成的空间中的一个点

5.通过计算每个词和文档的相似度(如余弦相似度),可以得到每个文档对每个词的相似度结果,相似度高的即为关键词。

具体地:假设我们词表中一共有$n$个词,那么对于一个文档,其可以被表示成一个$n$维向量,这个$n$维向量可以这样定义:对一个词语,如果这个词在文档中出现了对应分量即为1,否则为0(当然也可以定义成这个词在这个文档中的$tf-idf$值)

那么如果我们一共有$m$篇文档,我们将每篇文档的向量拼在一起,就得到了一个$m*n$的矩阵$A$,然后我们对这个矩阵做奇异值分解$A=U\Sigma V^{T}$,这里的$U,V$分别是$m*m,n*n$的酉矩阵(正交矩阵),而$\Sigma$是$m*n$的伪对角矩阵(即只有位置$(1,1),(2,2),...,(min(m,n),min(m,n))$上有非零元素)

那么我们选取前$k$大的奇异值(即$\Sigma$伪对角线上的元素)作为“主题”,那么我们可以得到一个$A$的近似表示:$\hat{A}=\hat{U}D\hat{V}^{T}$,其中$\hat{U}$是一个$m*k$的矩阵(即原来的$U$截取对应的$k$列),$D$是前$k$大奇异值为主对角元的对角矩阵,$\hat{V}$是一个$n*k$的矩阵(即原来的$V$截取$k$列)

那么这是什么?

这实际上给出的就是一个文档的主题分布和一个主题的词分布!

比如对于矩阵$\hat{U}$,它是一个$m*k$的矩阵,我们把这个$k$解释成$k$个主题,那么对应的矩阵元素值就是某篇文档和某个主题的相关性,同样对于矩阵$\hat{V}$,它是一个$n*k$的矩阵,对应的矩阵元素值就是某个词和某个主题的相关性,那么以主题作为中介,我们就把文档的语义和词义联系在了一起,这样我们就能计算出文档与文档之间的相似度,词与词之间的相似度,以及词与文档的相似度(对于这一点,如果词与文档在表示的主题的意义上相似度极高,就说明这个词很可能说了和这个文档关系密切的事情,那么这个词很可能是这篇文档的关键词。)!

当然了,这样的方法也有其内在的问题:首先是计算SVD的复杂度比较高,其次是其分布只能基于已有信息,每次新来一篇文档都要重新训练整个空间,同时其对于词的频率不敏感,物理解释性也比较差(比如如果计算出了负值的相关性,这是什么含义呢?)

LDA算法:

LDA(隐含狄利克雷分布)算法假定文档中主题的先验分布和主题中词的先验分布都服从狄利克雷分布,通过对已有数据集的观测得到后验分布。具体算法步骤如下:

1.对语料库中每篇文档的每个词随机赋予一个主题编号

2.重新扫描语料库,对每个词按照吉布斯采样公式重新采样其主题编号,在语料库中进行更新

3.重复以上语料库的重新采样过程直到吉布斯采样收敛

4.统计语料库的topic-word共现频率矩阵,该矩阵就是LDA模型

这样对于一个新来的文档,我们可以对新文档进行评估:

1.对新文档中每个词随机赋予一个主题编号

2.重新扫描当前文档,按照吉布斯采样公式重新采样其主题编号

3.重复以上过程直到吉布斯采样收敛

4.统计文档的topic分布即为结果

代码实现及其比较:

import math
import jieba
import jieba.posseg as psg
from gensim import corpora,models
from jieba import analyse
import functools
import Tfidf

class TopicModel(object):
    def __init__(self,doc_list,model,keyword_num=10,topic_num=5):
        #将文档转化为词表
        self.dic=corpora.Dictionary(doc_list)

        #将所有文档转化为矩阵
        corpus = [self.dic.doc2bow(doc) for doc in doc_list]

        #用tf-idf表示
        self.tfidf_model = models.TfidfModel(corpus)
        self.corpus_tfidf = self.tfidf_model[corpus]

        self.keyword_num=keyword_num
        self.topic_num=topic_num

        if model == 'lsi':
            self.model=self.train_lsi()
        else:
            self.model=self.train_lda()

        #将文档转化为词表(与之前步骤的类型不同)
        word_dic=self.word_dictionary(doc_list)

        #计算每个词的主题表示
        self.wordtopic_dic=self.get_wordtopic(word_dic)

    def train_lsi(self):
        lsi=models.LsiModel(self.corpus_tfidf,id2word=self.dic,num_topics=self.topic_num)
        return lsi

    def train_lda(self):
        lda=models.LdaModel(self.corpus_tfidf,id2word=self.dic,num_topics=self.topic_num)
        return lda

    def word_dictionary(self, doc_list):
        dictionary = []
        for doc in doc_list:
            dictionary.extend(doc)

        dictionary = list(set(dictionary))

        return dictionary

    def get_wordtopic(self,word_dic):
        wordtopic_dic=dict()

        for word in word_dic:
            #每个词的主题表示相当于一个只有这一个词的文档的主题表示
            l=[word]
            word_corpus=self.tfidf_model[self.dic.doc2bow(l)]

            wordtopic=self.model[word_corpus]
            wordtopic_dic[word]=wordtopic

        return wordtopic_dic

    def calc(self,l1,l2):
        #计算余弦相似度
        a,b,c=0.0,0.0,0.0
        for x,y in zip(l1,l2):
            l=x[1]
            r=y[1]
            a+=l*r
            b+=l*l
            c+=r*r
        return a/math.sqrt(b*c)


    def get_simword(self,word_list):
        #计算输入文档的主题表示
        sentcorpus=self.tfidf_model[self.dic.doc2bow(word_list)]
        senttopic=self.model[sentcorpus]


        sim_dic=dict()
        #计算输入文档与词表中每个词的相似度
        for i,j in self.wordtopic_dic.items():
            if i not in word_list:
                continue

            sim_dic[i]=self.calc(j,senttopic)

        sim=sorted(sim_dic.items(),key = lambda item:item[1],reverse=True)

        cnt=0
        for i in sim:
            print(i[0])
            cnt+=1
            if cnt>=10:
                break

def Topic_keyword(path,model='lsi',keyword_num=10):
    doc = Tfidf.read_doc(path)
    doc_list=Tfidf.load_data()
    topic_model=TopicModel(doc_list,keyword_num,model)
    topic_model.get_simword(doc)

上述代码用Genism包封装了一个主题模型,可选地采用lsi或lda模型计算关键词

import math
import jieba
import jieba.posseg as psg
from gensim import corpora,models
from jieba import analyse
import functools

def get_stopword_list():
    with open('./stopword.txt','r',encoding='utf-8') as f:
        stopword_list=[w.replace('\n','') for w in f.readlines()]
        return stopword_list

def word_sep(sentence):
    return jieba.cut(sentence)

def word_filter(wordlist):
    stopword=get_stopword_list()
    filter_list=[]
    for w in wordlist:
        if w not in stopword and len(w)>=2:
            filter_list.append(w)
    return filter_list

def load_data():
    with open('./corpus.txt','r',encoding='utf-8') as f:
        doc_list=[word_filter(word_sep(d.strip())) for d in f.readlines()]
        return doc_list

def train_idf(doc_list):
    idf_dic={}
    doc_cnt=len(doc_list)
    for i in doc_list:
        for j in set(i):
            if j in idf_dic:
                idf_dic[j]+=1
            else:
                idf_dic[j]=1

    for i in idf_dic:
        idf_dic[i]=math.log(doc_cnt/(1+idf_dic[i]))
   
    default_idf=math.log(doc_cnt)
    return idf_dic,default_idf

def read_doc(path):
    doc_list=[]
    with open(path, 'r', encoding='utf-8') as f:
        for d in f.readlines():
            d=word_filter(word_sep(d.strip()))
            for w in d:
                doc_list.append(w)
        return doc_list

def train_tf_idf(doc,idf_dic,default_idf):
    tf_dic=dict()
    tf_idf_dic = dict()
    size = len(doc)
    for i in doc:
        if i not in tf_dic:
            tf_dic[i]=1
            if i in idf_dic:
                tf_idf_dic[i] =  idf_dic[i]/size
            else:
                tf_idf_dic[i] = default_idf/size
        else:
            tf_dic[i]+=1
            if i in idf_dic:
                tf_idf_dic[i] = tf_dic[i]*idf_dic[i] / size
            else:
                tf_idf_dic[i] = tf_dic[i]*default_idf / size

    ret=sorted(tf_idf_dic.items(),key = lambda item:item[1],reverse=True)
    cnt=0
    for i in ret:
        print(i[0])
        cnt+=1
        if cnt>=10:
            break

def tfidf_keyword(path,keyword_num=10):
    doc = read_doc(path)
    doc_list=load_data()
    idf_dic,default_idf=train_idf(doc_list)
    train_tf_idf(doc,idf_dic,default_idf)

这部分代码实现了一个tf-idf关键词提取,其中读取数据部分应用了jieba进行分词

import TopicModel
import Tfidf

path='./news.txt'
Tfidf.tfidf_keyword(path)
TopicModel.Topic_keyword(path)

主程序如上所示

这里需要注意到一点:Tf-Idf对测试数据与训练数据的相关性要求较低,比如我可以使用一个文档集合进行训练,再用一篇没有出现在这个集合中的文档进行测试,在一些时候也能有较好的表现,但是主题模型(尤其是LSI模型)则不能保证这一点,如果你用了一篇内容和训练文档集相关程度较低的文档去测试的话得到的结果是不能使人满意的。

而如果使用文档集中的文档进行测试,可以看到二者的表现都是不错的。

posted @ 2022-04-19 22:31  lleozhang  Views(2379)  Comments(1Edit  收藏  举报
levels of contents