【TextRank】關鍵詞提取 算法原理 公式推導 源碼分析

1.前言

    在介紹TextRank前,我想先給大家介紹下PageRank,實質上個人認爲可以把TextRank當做PageRank2.0。

    谷歌的兩位創始人的佩奇和布林,借鑑了學術界評判學術論文重要性的通用方法,“那就是看論文的引用次數”。由此想到網頁的重要性也可以根據這種方法來評價。於是PageRank的核心思想就誕生了:

  • 如果一個網頁被很多其他網頁鏈接到的話說明這個網頁比較重要,也就是PageRank值會相對較高
  • 如果一個PageRank值很高的網頁鏈接到一個其他的網頁,那麼被鏈接到的網頁的PageRank值會相應地因此而提高

 2.PageRank算法原理

      從PageRank的核心思想出發,我們

  • \large S(v_{i})定義\large v_{i}這個頁面的PageRank值
  • \large \varepsilon來表示所有其他可以鏈接到\large v_{i}這個頁面的集合
  • 那麼便可以用\large (j,i)來定義集合中的其中一個(由頁面 \large v_{j}鏈接到頁面\large v_{i}

      我們可以給一個頁面的PageRank值做這樣的定義:

                                                      \large S(v_{i})=\sum _{(j,i)\in\varepsilon }S(v_{j})

    

sample1

以上圖爲例,PangRank的值就可以表示爲:

                                                         \large S(A)=S(C)+S(B)

這樣的計算有個很明顯的漏洞,可以看到除了C點,其他三個點都外鏈了2個節點,那麼對於B,D來說,是可以選擇不去A的而去另外一個鏈接。所以外鏈進入A是有一定概率的。針對這點修整PangRank的表示方法:

                                                        \large S(v_{i})=\sum _{(j,i)\in\varepsilon }\frac{S(v_{j})}{Out_{j}}

用矩陣的思想處理這個公式,設定:

  • \large S(v)=\bigl(\begin{smallmatrix} S(v_{1})\\ \cdot \cdot \cdot \cdot \cdot\\ \cdot \cdot \cdot \cdot \cdot\\ S(v_{n})\end{smallmatrix}\bigr)爲一個N維的PangRank值向量,
  • \large A爲所有頁面的集合,且A有這樣定義:
  • \large A_{ji}=\left\{\begin{matrix} \frac{1}{Out(v_{j})} & if (j,i)\in \varepsilon \\ 0& if (j,i)\notin \varepsilon \end{matrix}\right.

那麼對於大量PageRank,我們便可以轉化爲這樣的形式:

                                                             \large S(v)=A^{T}S(v)

這樣看似優化了很多,但是還存在一定的問題,我們得事先知道其他相關網站的PageRank值,才能得到指定網站的PageRank值。而其他網站的PageRank值還得從一些具有其鏈接的網站的PageRank值求得。這就變成了一個先有雞還是先有蛋的問題。

PageRank採用power iteration來解決這個問題:

我們給定\large S(v)一個初始值假定爲\large S(v)^{0},然後通過下式子不斷的迭代求解:

                                                             \large S(v)^{k}=A^{T}S(v)^{k-1}

 直到最後收斂於\large \left \| S(v)^{k}-S(v)^{k-1} \right \|<\xi,即差別小於某個閾值。

於是計算PageRank值的過程就變成了一個 Markov 過程,那麼PageRank算法的證明也就轉爲證明 Markov 過程的收斂性證明,

若一個 Markov 過程收斂,那麼它的狀態轉移矩陣\large A需要滿足:

  • stochastic matrix:則行至少存在一個非零值,即必須存在一個外鏈接(沒有外鏈接的網頁被稱爲dangling pages);
  • 不可約(irreducible):即矩陣\large A所對應的有向圖\large G必須是強連通的,對於任意兩個節點\large (j,i)\in \varepsilon,存在一個從\large j\large i的路徑;
  • 非週期性(aperiodic):即每個節點存在自迴路。

顯然,在一般情況下,這樣的情況都是不滿足的,爲了滿足性質stochastic matrix,可以把全爲0的行替換爲\large \frac{e}{n},其中\large e爲單位向量;同時爲了滿足性質不可約、非週期,需要做平滑處理:     

個人覺得算法領域很多公式的可解釋性不強,大多是爲了解決某些限定條件,而人爲選擇的比較好的優化方式,比如下面公式,把\large A的矩陣乘以的一個阻尼係數d,(這個d通常取0.85實踐出來的,好像沒什麼數學解釋),實質上是爲了給數據做一個平滑處理,都加上一個(1-d)\frac{E}{n}實質是爲了替代爲零項

                                                                        \dpi{100} \large \dpi{120} \large S(v)=(1-d)\frac{E}{n}+dA^{T}

於是

                                                                        \large S(v_{i})=\sum _{(j,i)\in\varepsilon }\frac{S(v_{j})}{Out_{j}}

就被改寫爲:

                                                                        \large S(v_{i})=(1-d)+d\sum _{(j,i)\in \varepsilon }\frac{S(v_{j})}{Out_{j}}

3.TextRank算法原理

     用TextRank提取來提取關鍵詞,用PageRank的思想來解釋它:

  • 如果一個單詞出現在很多單詞後面的話,那麼說明這個單詞比較重要
  • 一個TextRank值很高的單詞後面跟着的一個單詞,那麼這個單詞的TextRank值會相應地因此而提高

這樣TextRank的公式就可以由PageRank公式改寫爲:

                                                      \large S(v_{i})=(1-d)+d\sum_{(j,i)\in \varepsilon }\frac{w_{ji}}{\sum_{v_{k}\in out(v_{j})}w_{jk}}S(v_{j})

公式的意思很明顯:

     TextRank中一個單詞\large i的權重取決於與在\large i前面的各個點\large j組成的\large (j,i)這條邊的權重,以及\large j這個點到其他其他邊的權重之和。

 

4.python編程實現

   因爲在瞭解textrank的時候,參考了jieba分詞和TextRank4zh這2個開源庫的寫法。但是兩者無論寫法和運算規則都有很大出入,結合公式來說本人覺得jieba做的更符合公式,TextRank4zh更具有準確性,因爲TextRank4zh在公式上面做了一定的優化。

Jieba分詞TextRank:

  • 對每個句子進行分詞和詞性標註處理
  • 過濾掉除指定詞性外的其他單詞,過濾掉出現在停用詞表的單詞,過濾掉長度小於2的單詞
  • 將剩下的單詞中循環選擇一個單詞,將其與其後面4個單詞分別組合成4條邊。

例如:         ['有','媒體', '曝光','高圓圓', '和', '趙又廷','現身', '臺北', '桃園','機場','的', '照片']
        對於‘媒體‘這個單詞,就有('媒體', '曝光')、('媒體', '圓')、('媒體', '和')、('媒體', '趙又廷')4條邊,且每條邊權值爲1,當這條邊在之後再次出現時,權值再在基礎上加1.

  • 有了這些數據後,我們就可以構建出候選關鍵詞圖G=(V,E),圖的概念有基礎的人可能會很好理解,不理解其實也沒關係,按上面例子,你只用知道這一步我們把2個單詞組成的邊,和其權值記錄了下來。
  • 這樣我們就可以套用TextRank的公式,迭代傳播各節點的權值,直至收斂。
  • 對結果中的Rank值進行倒序排序,篩選出前面的幾個單詞,就是我們需要的關鍵詞了。
def textrank(self, sentence, topK=20, withWeight=False, allowPOS=('ns', 'n', 'vn', 'v'), withFlag=False):

    self.pos_filt = frozenset(allowPOS)
    # 定義無向有權圖
    g = UndirectWeightedGraph()
    # 定義共現詞典
    cm = defaultdict(int)
    # 分詞
    words = tuple(self.tokenizer.cut(sentence))
    # 依次遍歷每個詞
    for i, wp in enumerate(words):
        # 詞i 滿足過濾條件
        if self.pairfilter(wp):
            # 依次遍歷詞i 之後窗口範圍內的詞
            for j in xrange(i + 1, i + self.span):
                # 詞j 不能超出整個句子
                if j >= len(words):
                    break
                # 詞j不滿足過濾條件,則跳過
                if not self.pairfilter(words[j]):
                    continue
                # 將詞i和詞j作爲key,出現的次數作爲value,添加到共現詞典中
                if allowPOS and withFlag:
                    cm[(wp, words[j])] += 1
                else:
                    cm[(wp.word, words[j].word)] += 1
    # 依次遍歷共現詞典的每個元素,將詞i,詞j作爲一條邊起始點和終止點,共現的次數作爲邊的權重
    for terms, w in cm.items():
        g.addEdge(terms[0], terms[1], w)
    
    # 運行textrank算法
    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)

    # 輸出topK個詞作爲關鍵詞
    if topK:
        return tags[:topK]
    else:
        return tags

 關於有向圖的數據結構:

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))

關於TextRank的手寫:

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
        # 遍歷各個結點
        for n in sorted_keys:
            s = 0
            # 遍歷結點的入度結點
            for e in self.graph[n]:
                # 將這些入度結點貢獻後的權值相加
                # 貢獻率 = 入度結點與結點n的共現次數 / 入度結點的所有出度的次數
                s += e[2] / outSum[e[1]] * ws[e[1]]
            # 更新結點n的權值
            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

jieba.analyse.textrank完整源碼如下:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import absolute_import, unicode_literals
import sys
from operator import itemgetter
from collections import defaultdict
import jieba.posseg
from .tfidf import KeywordExtractor
from .._compat import *


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
            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

    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):
            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

    extract_tags = textrank

 

打賞一下作者:

 

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