如何用Java实现NLP的经典关键词算法 TF-IDF

面对一篇文章,我们如何提取他的关键词呢。如果是我们自己去提取,那只需要读一遍,然后大脑中就会有一定的印象了,但是对于计算机来说,他没有人那样的思考能力啊,那怎么办,只能依靠算法了。今天分享的内容呢是如何用Java语言实现NLP(自然语言处理)领域中一个非常著名的算法 TF-IDF(Term Frequency–Inverse Document Frequency 词频-逆向文档频率算法)。读懂这篇文章需要有一点点的数理基础和Java基础。

原理部分

如果不想看我啰里啰嗦讲原理的童鞋,可以直接跳到代码部分哦

假定我们要为以下的文字提取关键词,原文来自朱自清先生的《背影》:

我说道,“爸爸,你走吧。”他望车外看了看,说,“我买几个橘子去。你就在此地,不要走动。”我看那边月台的栅栏外有几个卖东西的等着顾客。走到那边月台,须穿过铁道,须跳下去又爬上去。父亲是一个胖子,走过去自然要费事些。我本来要去的,他不肯,只好让他去。我看见他戴着黑布小帽,穿着黑布大马褂,深青布棉袍,蹒跚地走到铁道边,慢慢探身下去,尚不大难。可是他穿过铁道,要爬上那边月台,就不容易了。他用两手攀着上面,两脚再向上缩;他肥胖的身子向左微倾,显出努力的样子。这时我看见他的背影,我的泪很快地流下来了。我赶紧拭干了泪,怕他看见,也怕别人看见。我再向外看时,他已抱了朱红的橘子望回走了。过铁道时,他先将橘子散放在地上,自己慢慢爬下,再抱起橘子走。到这边时,我赶紧去搀他。他和我走到车上,将橘子一股脑儿放在我的皮大衣上。于是扑扑衣上的泥土,心里很轻松似的,过一会说,“我走了;到那边来信!”我望着他走出去。他走了几步,回过头看见我,说,“进去吧,里边没人。”等他的背影混入来来往往的人里,再找不着了,我便进来坐下,我的眼泪又来了。

朱自清先生是我非常崇敬的一位作家,我非常喜欢他的作品。但是问题来了,计算机是没有情感的,他怎么能读懂这几段文字并提取关键词呢?

首先,我们需要计算机进行一个叫分词的操作,就是把文章按照一定的规则进行切分,切成一个一个的短语。分词的算法非常复杂,这里不作过多的阐述,有兴趣的朋友可以上网搜下相关的资料。

分词后我们要想个问题,是不是某一段中经常出现的词,是不是作者非常强调的词呢,那成为关键词的机会是不是越大呢。这里我们就要引入一个叫词频(TF)的概念了:

$$ TF = 某个词在文档中的出现次数 $$

好了,讲到这里我们需要思考一个问题,像上面的文章中,有很多的“的”、“地”这样的结构助词,出现的频率相当高,我们怎么处理呢。工业界和学术界在处理语料数据的时候,会称这些词为“停用词”,字面意思,就是我们在处理语料的时候,忽略掉这些词。常见的做法会在分词的时候,建立停用词词典,直接把这些词从文档中去掉。

然后,获得了词频,你会有疑问了,那是不是直接把词频最高的Top10或者Top5作为关键词不就行了吗,为啥这算法还有个叫逆文档频率的东西啊....

你想想啊,什么是关键词啊,关键词就是某个文档独有的一些词语,而不是每篇都有的。也就是比如某个词在文档A出现了,在BCD没出现,那么我们很大的几率就认为这个词是文档A的关键词。这个时候,登登登登,逆文档频率就出现了:

$$ IDF = log(\frac{语料库中文档的总数}{语料库中包含该词语的文档数+1}) $$

这里我们有个概念,语料库 Corpus。语料库是指一个非常庞大的自然语言数据集合,里面有大量的文章语料可以供计算机使用。我们话说回来,观察上面的式子,因为语料库中包含该词语的文档数是分子,所以如果包含某个词语的文档越少,他的IDF值会越高,那么这个词语很有可能就是这些文档的关键词。有同学观察到分母有个1,这个是为了避免没有任何一篇文章包含这个词导致了分母为0的情况!

好了,我们讲到这里,我们发现词频越高的词越有可能成为关键词,逆向文档频率越高的词越有可能代表这个文档呢。引用高中数学的话,TF,IDF都与某个词是不是关键词的概率成正比关系。那好办,我们把两个值乘起来,作为某个词在某个文档中的频率。对于词语Wi,我们规定其TF-IDF值为:

$$ TF-IDF_i = TF_i \times IDF_i $$

有句话叫“光说不练假把式”,我们就以前面引用的朱自清先生的段落为例,我们以朱自清先生的这篇《背影》作为语料库,每个自然段作为一个文档;使用分词器,抽取里面的名词、动词和形容词进行分析,分析得到这样的数据:

zhuziqing-beiying.png

可以看到表格中,这一个自然段橘子的出现频率非常高,而其逆文档频率也相当高,因此“橘子”是这一段的一个关键词。然后“背影”由于在多个自然段都出现了,因此其逆向文档频率就非常低了,近乎为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的准确性,可以考虑在分词的时候,向分词器提供停用词表和自定义分词!!!

啰里啰嗦的部分

有同学问,上述算法到底可不可行的,当然可以了。我所在的天高科技工作室内部工作平台TentcooTools已经应用了TF-IDF对每一篇的同学周报进行关键词抽取操作,并且这个关键词抽取目测还是挺准的。工作室17年的年度关键词也是通过这个算法生成的。

标签: java, nlp
返回文章列表 文章二维码
本页链接的二维码
打赏二维码
评论列表
  1. 你玩晒啦,甘劲 ʕ•ٹ•ʔ

添加新评论