1. 程式人生 > >如何用Java實現NLP的經典關鍵詞演算法 TF-IDF

如何用Java實現NLP的經典關鍵詞演算法 TF-IDF

面對一篇文章,我們如何提取他的關鍵詞呢。如果是我們自己去提取,那隻需要讀一遍,然後大腦中就會有一定的印象了,但是對於計算機來說,他沒有人那樣的思考能力啊,那怎麼辦,只能依靠演算法了。今天分享的內容呢是如何用Java語言實現NLP(自然語言處理)領域中一個非常著名的演算法 TF-IDF(Term Frequency–Inverse Document Frequency 詞頻-逆向文件頻率演算法)。讀懂這篇文章需要有一點點的數理基礎和Java基礎。

講在前面的話:
我是渣渣小本科一枚,對機器學習有些興趣,平時喜歡在網上寫寫學習筆記什麼的,文章有不恰當或者不合理的地方,歡迎大家指出。

原理部分

如果不想看我囉裡囉嗦講原理的童鞋,可以直接跳到程式碼部分哦

假定我們要為以下的文字提取關鍵詞,原文來自朱自清先生的《背影》:

我說道,“爸爸,你走吧。”他望車外看了看,說,“我買幾個橘子去。你就在此地,不要走動。”我看那邊月臺的柵欄外有幾個賣東西的等著顧客。走到那邊月臺,須穿過鐵道,須跳下去又爬上去。父親是一個胖子,走過去自然要費事些。我本來要去的,他不肯,只好讓他去。我看見他戴著黑布小帽,穿著黑布大馬褂,深青布棉袍,蹣跚地走到鐵道邊,慢慢探身下去,尚不大難。可是他穿過鐵道,要爬上那邊月臺,就不容易了。他用兩手攀著上面,兩腳再向上縮;他肥胖的身子向左微傾,顯出努力的樣子。這時我看見他的背影,我的淚很快地流下來了。我趕緊拭乾了淚,怕他看見,也怕別人看見。我再向外看時,他已抱了硃紅的橘子望回走了。過鐵道時,他先將橘子散放在地上,自己慢慢爬下,再抱起橘子走。到這邊時,我趕緊去攙他。他和我走到車上,將橘子一股腦兒放在我的皮大衣上。於是撲撲衣上的泥土,心裡很輕鬆似的,過一會說,“我走了;到那邊來信!”我望著他走出去。他走了幾步,回過頭看見我,說,“進去吧,裡邊沒人。”等他的背影混入來來往往的人裡,再找不著了,我便進來坐下,我的眼淚又來了。

朱自清先生是我非常崇敬的一位作家,我非常喜歡他的作品。但是問題來了,計算機是沒有情感的,他怎麼能讀懂這幾段文字並提取關鍵詞呢?

首先,我們需要計算機進行一個叫分詞的操作,就是把文章按照一定的規則進行切分,切成一個一個的短語。分詞的演算法非常複雜,這裡不作過多的闡述,有興趣的朋友可以上網搜下相關的資料。

分詞後我們要想個問題,是不是某一段中經常出現的詞,是不是作者非常強調的詞呢,那成為關鍵詞的機會是不是越大呢。這裡我們就要引入一個叫詞頻(TF)的概念了:

TF=

好了,講到這裡我們需要思考一個問題,像上面的文章中,有很多的“的”、“地”這樣的結構助詞,出現的頻率相當高,我們怎麼處理呢。工業界和學術界在處理語料資料的時候,會稱這些詞為“停用詞”,字面意思,就是我們在處理語料的時候,忽略掉這些詞。常見的做法會在分詞的時候,建立停用詞詞典,直接把這些詞從文件中去掉。

然後,獲得了詞頻,你會有疑問了,那是不是直接把詞頻最高的Top10或者Top5作為關鍵詞不就行了嗎,為啥這演算法還有個叫逆文件頻率的東西啊….

你想想啊,什麼是關鍵詞啊,關鍵詞就是某個文件獨有的一些詞語,而不是每篇都有的。也就是比如某個詞在文件A出現了,在BCD沒出現,那麼我們很大的機率就認為這個詞是文件A的關鍵詞。這個時候,登登登登,逆文件頻率就出現了:

IDF=log(+1)

這裡我們有個概念,語料庫 Corpus。語料庫是指一個非常龐大的自然語言資料集合,裡面有大量的文章語料可以供計算機使用。我們話說回來,觀察上面的式子,因為語料庫中包含該詞語的文件數是分子,所以如果包含某個詞語的文件越少,他的IDF值會越高,那麼這個詞語很有可能就是這些文件的關鍵詞。有同學觀察到分母有個1,這個是為了避免沒有任何一篇文章包含這個詞導致了分母為0的情況!

好了,我們講到這裡,我們發現詞頻越高的詞越有可能成為關鍵詞,逆向文件頻率越高的詞越有可能代表這個文件呢。引用高中數學的話,TF,IDF都與某個詞是不是關鍵詞的概率成正比關係。那好辦,我們把兩個值乘起來,作為某個詞在某個文件中的頻率。對於詞語Wi,我們規定其TF-IDF值為:

TFIDFi=TFi×IDFi

有句話叫“光說不練假把式”,我們就以前面引用的朱自清先生的段落為例,我們以朱自清先生的這篇《背影》作為語料庫,每個自然段作為一個文件;使用分詞器,抽取裡面的名詞、動詞和形容詞進行分析,分析得到這樣的資料:

分析結果表

可以看到表格中,這一個自然段橘子的出現頻率非常高,而其逆文件頻率也相當高,因此“橘子”是這一段的一個關鍵詞。然後“背影”由於在多個自然段都出現了,因此其逆向文件頻率就非常低了,近乎為0,因此背影不能作為這一段的一個關鍵詞。因此我們可以指定這段的關鍵詞可以是“橘子”,“鐵道”和“月臺”等。也大致滿足了這一段的主題:父親越過鐵道為作者買橘子,引發了作者的深思。

其實,如果讀過小學語文課本的同學都會說,這一段,不是父親的背影才是引發作者深思的點嗎,為什麼沒有在關鍵詞裡。這裡有一個非常不嚴謹的點,我們在技術TF-IDF時,使用的語料集非常狹隘,僅僅是這篇文章,這篇文章的主題恰恰就是“背影”啊,這麼多年的語文課告訴我們,作者需要不斷進行點題強調,因此造成了IDF低的問題。如果我們的語料庫足夠豐富,而且我們分析的不僅僅是一個自然段,是一整篇的《背影》我相信效果會更加理想的。

說了那麼多的原理,我們就來說說Java怎麼可以實現TF-IDF演算法(單機版哦~):

Java實現部分

Java部分我們需要使用到Ansj分詞器進行分詞

Ansj分詞器的Github:傳送門

使用Maven的同學可以使用:

<dependencies>
    <dependency>
        <groupId>org.ansj</groupId>
        <artifactId>ansj_seg</artifactId>
        <version>5.1.1</version>
    </dependency>
</dependencies>

然後程式碼的實現非常簡單,先把程式碼全部貼出來:

public class TfIdfUtil {

    // 文件表
    private List<String> documents;
    // 文件與詞彙列表
    private List<List<String>> documentWords;
    // 文件詞頻統計表
    private List<Map<String,Integer>> docuementTfList;
    // 詞彙出現文件數統計表
    private Map<String,Double>  idfMap;
    // 是否只抽取名詞
    private boolean onlyNoun = false;

    public TfIdfUtil(List<String> documents) {
        this.documents = documents;
    }

    public List<Map<String,Double>> eval(){
        this.splitWord();
        this.calTf();
        this.calIdf();
        return calTfIdf();
    }

    /**
     * 獲取所有文件數,用於逆文件頻率 IDF 的計算
     * @author Shaobin.Ou
     * @return 所有文件數
     */
    private int getDocumentCount() {
        return documents.size();
    }

    /**
     * 對每一個文件進行詞語切分
     * @author Shaobin.Ou
     */
    private void splitWord() {
        documentWords = new ArrayList<List<String>>();
        for( String document : documents ) {
            Result splitWordRes = DicAnalysis.parse(document);
            List<String> wordList = new ArrayList<String>();
            for(Term term : splitWordRes.getTerms()) {
                if(onlyNoun) {
                    if(term.getNatureStr().equals("n")||term.getNatureStr().equals("ns")||term.getNatureStr().equals("nz")) {
                        wordList.add(term.getName());
                    }
                }else {
                    wordList.add(term.getName());
                }
            }
            documentWords.add(wordList);
        }
    }

    /**
     * 對每一個文件進行詞頻的計算
     * @author Shaobin.Ou
     */
    private void calTf() {
        docuementTfList = new ArrayList<Map<String,Integer>>();
        for(List<String> wordList : documentWords ) {
            Map<String,Integer> countMap = new HashMap<String,Integer>();
            for(String word : wordList) {
                if(countMap.containsKey(word)) {
                    countMap.put(word, countMap.get(word)+1);
                }else {
                    countMap.put(word, 1);
                }
            }
            docuementTfList.add(countMap);
        }
    }

    /**
     * 計算逆文件頻率 IDF
     * @author Shaobin.Ou
     */
    private void calIdf() {
        int documentCount = getDocumentCount();
        idfMap = new HashMap<String,Double>();
        // 統計詞語在多少文件裡面出現了
        Map<String,Integer> wordAppearendMap = new HashMap<String,Integer>();
        for(Map<String,Integer> countMap : docuementTfList  ) {
            for(String word : countMap.keySet()) {
                if(wordAppearendMap.containsKey(word)) {
                    wordAppearendMap.put(word, wordAppearendMap.get(word)+1);
                }else {
                    wordAppearendMap.put(word, 1);
                }
            }
        }
        // 對每個詞語進行計算
        for(String word : wordAppearendMap.keySet()) {
            double idf = Math.log( documentCount  / (wordAppearendMap.get(word)+1)  );
            idfMap.put(word, idf);
        }
    }

    private List<Map<String,Double>> calTfIdf() {
        List<Map<String,Double>> tfidfRes = new ArrayList<Map<String,Double>>();
        for(Map<String,Integer> docuementTfMap : docuementTfList ) {
            Map<String,Double> tfIdf = new HashMap<String,Double>();
            for(String word : docuementTfMap.keySet()) {
                double tfidf = idfMap.get(word) * docuementTfMap.get(word);
                tfIdf.put(word, tfidf);
            }
            tfidfRes.add(tfIdf);
        }
        return tfidfRes;
    }

}

首先在TfIdfUtil類例項化的時候,我們需要傳入一個List,List的每一個字串元素都一個文件。
然後,執行eval()方法

eval()方法的執行流程是這樣的
首先執行 splitWord() 進行分詞 -> 執行 calTf() 計算每個分析的詞頻 -> 執行calIdf()計算逆向詞頻 -> 執行 calTfIdf() 計算每個詞的TF-IDF

Notes 注意了
為了提高TF-IDF的準確性,可以考慮在分詞的時候,向分詞器提供停用詞表和自定義分詞!!!