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模型)則不能保證這一點,如果你用了一篇內容和訓練文檔集相關程度較低的文檔去測試的話得到的結果是不能使人滿意的。

而如果使用文檔集中的文檔進行測試,可以看到二者的表現都是不錯的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章