全文检索、数据挖掘、推荐引擎系列5---文章术语向量表示法
无论是要进行全文检索,还是对文章进行自动聚类分析,都需要将文章表示为术语向量(Term Vector),在Lucene内部就是通过术语向量来对文章进行索引和搜索的,但是Lucene没有向外提供合适的术语向量计算接口,所以对术语向量计算还必须我们自己来做。
术语向量解述
众所周知,一篇文章由一个个的单词组成,我们在进行文本处理时,首先进行中文分词,包括去除“的、地、得”等常用停止词,对关键词加上同义词,如缩写和全称,如果是英文可能还需要变为小写,去除复数和过去分词等,可能还需要提取词根,总之经过上述步聚的预处理,文章将变成由一系列单词组成的字符串数组。
对一系统中的每一篇文章,我们首先计算每个单词的出现频率(TF:TermFrequency),即该单词出现的次数除以文章总单词数,然后统计这个单词的反比文档频率(IDF:Inverse Document Frequency),在所有文章中出现的次数,并用该数除文章总数,即总文章数除以出现该单词文章的数目。由上面的定义可以看出,单词越重要,他的单词出现频率TF就越高,单词越是只在这篇文章中出现,很少在其它文章中出现,那该单词越对本篇文章具有重要意义。通过一定的公式,可以计算出每个单词的对每篇文章的权重,这样所有单词加上其对应的权重,就形成了一个多维术语向量。
计算TF*IDF
对于术语向量的计算方法,目前还没有特别成熟的算法,现在常用的只是一些经验算法,一些文章中提出的号称更加准确的算法,还没有经过实际验证。我们在这里采用的是当前最常用的算法,根据实际需要对这些算法进行修正也是相当简单的。
首先系统需要维护几个全局变量:总文章数、系统中所有单词以及其在文章中出现的次数
// 因为单词出现在文章的不同位置重要性不同,可以设置不同的权重
public final static int TITLE_WEIGHT = 1;
public final static int KEYWORD_WEIGHT = 1;
public final static int TAG_WEIGHT = 1;
public final static int ABCT_WEIGHT = 1;
public final static int BODY_WEIGHT = 1;
private static int docsNum = 0; // 目前系统中的总文档数,将来需要存在数据库中
private static Map<String, Integer> wordDocs = null; // 每个单词在所有的每篇文章中出现的文章个数(文章数)
private static Vector<Integer> termNumDoc = null; // 每篇文章的总单词数
private static Vector<Vector<TermInfo>> termVectors = null; // 每篇文章的术语向量表示
然后是对一段文本产生术语原始术语向量的程序,如下所示:
/**
* 一篇文章分为标题、关键字、摘要、标志、正文几个部分组成,每个部分的单词具有不同的权重,通过
* 本函数进行中文分词,同时生成该部分的术语向量
* @param text 需要处理的文本
* @param termArray 术语向量
* @param weight 单词在本部分的权重
* @return 本部分的单总数(用于计算单词出现频率TF)
*/
private static int procDocPart(String text, Vector<TermInfo> termArray, int weight) {
Collection<String> words = FteEngine.tokenize(text);
Iterator<String> itr = words.iterator();
String word = null;
TermInfo termInfo = null;
int termMount = 0;
while (itr.hasNext()) {
word = itr.next();
if (termArray.contains(word)) {
termInfo = termArray.get(termArray.indexOf(word));
termInfo.setMountPerDoc(termInfo.getMountPerDoc() + weight);
} else {
termInfo = new TermInfo();
termInfo.setMountPerDoc(weight);
termInfo.setTermStr(word);
termInfo.setRawWeight(0.0);
termInfo.setWeight(0.0);
termArray.add(termInfo);
}
termMount += weight;
}
return termMount;
}
下面是求出TF*IDF然后进行归一化生成最终术语向量的程序:
/**
* 对标题、关键字、标记、摘要、正文采用迭加方式生成术语向量
* @param docIdx 文档编号,为-1时表示新加入的文档
* @param text 需要处理的文本
* @param weight 本段文本单词出现的权重
* @return 文档编号
*/
public static int genTermVector(int docIdx, String text, int weight) {
Vector<TermInfo> termVector = null;
if (docIdx < 0) {
docIdx = docsNum;
termNumDoc.add(0);
termVector = new Vector<TermInfo>();
termVectors.add(termVector);
docsNum++;
}
termVector = termVectors.elementAt(docIdx);
int termMount = procDocPart(text, termVector, weight);
termNumDoc.set(docIdx, termNumDoc.elementAt(docIdx).intValue() + termMount);
// 计算所有术语的IDF
TermInfo termInfo = null;
String termStr = null;
Iterator<TermInfo> termInfoItr = termVector.iterator();
// 计算每个单词在文章中出现的篇数
while (termInfoItr.hasNext()) {
termInfo = termInfoItr.next();
termStr = termInfo.getTermStr();
if (wordDocs.get(termStr) != null) {
wordDocs.put(termStr, wordDocs.get(termStr).intValue() + 1);
} else {
wordDocs.put(termStr, 1);
}
termInfo.setTf(termInfo.getMountPerDoc() / ((double)termNumDoc.elementAt(docIdx).intValue()));
}
Iterator<Vector<TermInfo>> docItr = termVectors.iterator();
// 计算TF*IDF
double rwPSum = 0.0;
while (docItr.hasNext()) {
termVector = docItr.next();
termInfoItr = termVector.iterator();
rwPSum = 0.0;
while (termInfoItr.hasNext()) {
termInfo = termInfoItr.next();
termInfo.setRawWeight(termInfo.getTf() * Math.log(((double)docsNum) /
wordDocs.get(termInfo.getTermStr()).intValue()));
rwPSum += termInfo.getRawWeight() * termInfo.getRawWeight();
}
// 对TF*IDF进行归一化
termInfoItr = termVector.iterator();
while (termInfoItr.hasNext()) {
termInfo = termInfoItr.next();
termInfo.setWeight(termInfo.getRawWeight() / Math.sqrt(rwPSum));
}
}
return docIdx;
}
文章相似度计算
文章的相似度就是要计处两篇文章对应的术语向量的距离,也就是对应各个术语归一化后的TF*IDF的权重差的平方合再开发,类似于二维矢量距离的计算,具体实现代码如下所示:
/**
* 计算术语向量的距离,该值小则表明两篇文章相似度高
* @param termVector1
* @param termVector2
* @return 距离
*/
public static double calTermVectorDist(Collection<TermInfo> termVector1, Collection<TermInfo> termVector2) {
double dist = 0.0;
Vector<TermInfo> tv1 = (Vector<TermInfo>)termVector1;
Vector<TermInfo> tv2 = (Vector<TermInfo>)termVector2;
Hashtable<String, TermInfo> tv2Tbl = new Hashtable<String, TermInfo>();
Iterator<TermInfo> tvItr = null;
TermInfo termInfo = null;
TermInfo ti2 = null;
double[] weights = new double [tv2.size()];
int idx = 0;
// 初始化数据
tvItr = tv2.iterator();
while (tvItr.hasNext()) {
termInfo = tvItr.next();
//weights[idx++] = termInfo.getWeight();
tv2Tbl.put(termInfo.getTermStr(), termInfo);
}
//
tvItr = tv1.iterator();
while (tvItr.hasNext()) {
termInfo = tvItr.next();
ti2 = tv2Tbl.get(termInfo.getTermStr());
if (ti2 != null) {
dist += (termInfo.getWeight() - ti2.getWeight()) * (termInfo.getWeight() - ti2.getWeight());
ti2.setWeight(0.0);
} else {
dist += termInfo.getWeight() * termInfo.getWeight();
}
}
tvItr = tv2Tbl.values().iterator();
while (tvItr.hasNext()) {
termInfo = tvItr.next();
System.out.println("######: " + termInfo.getTermStr() + "=" + termInfo.getWeight() + "!");
dist += termInfo.getWeight() * termInfo.getWeight();
}
System.out.println();
return Math.sqrt(dist);
}
下面对以下三句话进行计算:
Java语言编程技术详解
C++语言编程指南
同性恋网站变身电子商务网站
计算的术语向量值为:
java:0.5527962688403749
语言:0.20402065516569604
编程:0.20402065516569604
技术:0.5527962688403749
详解:0.5527962688403749
############## doc2 ############
c:0.6633689723434504 (注:我们的词典中没有C++)
语言:0.24482975009584626
编程:0.24482975009584626
指南:0.6633689723434504
############## doc3 ############
同性恋:0.531130184813292
网:0.196024348194679
站:0.196024348194679
变身:0.531130184813292
电子商务:0.531130184813292
网:0.196024348194679
站:0.196024348194679
然后计算距离为:
第一篇与第二篇:1.3417148340558687
第一篇与第三篇:1.3867764455130116
因此通过计算结果系统会认为第一篇和第二篇更接近,实际情况也是如此,因为第一篇和第二篇间有两个单词是相同的,而第一篇和第三篇间则没有任何相同的地方。