原理部分

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

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

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

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

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

分词后我们要想个问题,是不是某一段中经常出现的词,是不是作者非常强调的词呢,那成为关键词的机会是不是越大呢。这里我们就要引入一个叫词频(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的同学可以使用:

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>org.ansj</groupId>
<artifactId>ansj_seg</artifactId>
<version>5.1.1</version>
</dependency>
</dependencies>

然后代码的实现非常简单,先把代码全部贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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年的年度关键词也是通过这个算法生成的。