赞
踩
最近在做一个基于SSM的Web项目,其中有一项功能是 对相似文本进行合并 ,其中涉及一个文本间相似度计算的问题。在此将实现过程记录下来。
名称 | 版本 |
---|---|
操作系统 | Win10 X64 |
JDK | 1.8.0_144 |
InteIliJ IDEA | 2020.1 |
Tomcat | 9.0.29 |
开始前有三个技术问题待解决 :
Han Language Processing 是一个自然语言处理工具包,基于PyTorch和TensorFlow 2.x双引擎实现。可以在多种语言环境下引入HanLP包,利用其中封装好的API进行快捷的NLP开发。
我构建的是一个 Maven 项目,因此只需要在项目的pom.xml
文件中引入 hanlp 依赖即可。
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>hanlp</artifactId>
<version>portable-1.8.1</version>
</dependency>
但是我在引入包后运行程序,仍然出现了 Error:(3, 24) java: 程序包com.hankcs.hanlp不存在
,解决方法是在 File -> Settings -> Build,Execution,Deployment -> Build Tools -> Maven -> Runner 中勾选 Delegate IDE build/run actions to Maven。
参考解答:https://zhuanlan.zhihu.com/p/142583125
但这样处理之后似乎有一个弊端,就是 Maven 的 Build 进程会循环运行,拖慢整个 Web 应用(甚至是整台电脑)的反应时间。
后来这个方法对电脑运行的拖累实在是太大了,于是笔者不得已找了另一个方法。系统提示找不到包,那么直接包缺少的包下载下来引入就行了。步骤如下:
Open Module Settings
,或者 File -> Project Structure
打开 1 所示窗口,按如下步骤进行操作,将 lib 中的 jar 包导入项目。利用 HanLP 中的 segment 函数进行中文分词,代码如下。
import com.hankcs.hanlp.HanLP;
import com.hankcs.hanlp.seg.common.Term;
public void test01(){
String text = "我在吉林大学软件学院学习计算机。今天是阳光明媚的一天";
List<Term> words= HanLP.segment(text);
for (Term word : words) {
System.out.print(word.word + ",");
}
}
得到结果如下:
segment
函数具有中文分词功能,可以将作为参数传入的文本分成独立单词。Term
代表一个单词,用户可以直接访问此单词的全部属性。单词包括 word(词语)
、Nature(词性)
、offset(文中起始位置)
三个属性,均为 public 类型,可直接访问。Nature 是一个枚举类,其取值范围很广,以下罗列一些较为常见的词型:
取值 | 含义 | 说明 |
---|---|---|
r | 代词 | r 指代词,r* 可代表不同类的代词。如:rr 是人称代词,ry 是疑问代词 etc. |
v | 动词 | v 指动词,v* 可代表不同类的动词。如:vd 是副动词,vn 是名动词 etc. |
ns | 地名 | 如:吉林、长春 |
u* | 助词 | uj、ud 指助词,ul、uv 指连词 etc. |
m | 数词 | m 指数词 |
q | 量词 | q 指量词 |
n | 名词 | n 指名词,n* 代表具体名词种类。如:nr 是人名,nrf 是音译人名 |
w | 标点符号 | w* 代表具体的标点符号。如:wkz 代表左括号,ww 代表问号 |
d | 副词 | 如:真、太 |
p | 介词 | p 代表介词,特殊的:pba – 把;pbei – 被 |
c | 连词 | 如:而且、但是 |
a | 形容词 | a* 代表具体的形容词种类。如:ad 是副形词;an 是名形词 |
y | 语气词 | 如:啊,诶 |
假设有两个文本 A 和 B,计算它们的相似程度。不妨建立一个工具类来做这个工作。
public class MyTextComparator{}
第一步是将一个文本转换为一组词序列,这些词应该是有实际意义的。这里,我取了名词、动词、形容词、动名词四种词进行保留。
// 提取文本中有实意的词 public static List<String> extractWordFromText(String text){ // resultList 用于保存提取后的结果 List<String> resultList = new ArrayList<>(); // 当 text 为空字符串时,使用分词函数会报错,所以需要提前处理这种情况 if(text.length() == 0){ return resultList; } // 分词 List<Term> termList = HanLP.segment(text); // 提取所有的 1.名词/n ; 2.动词/v ; 3.形容词/a ; 4.动名词/vn for (Term term : termList) { if(term.nature == Nature.n || term.nature == Nature.v || term.nature == Nature.a || term.nature == Nature.vn){ resultList.add(term.word); } } return resultList; }
得到结果如下:
看到这个结果,自然而然想到的一个问题是:究竟什么样的词更应该被保留下来?比如:第二句话的“认为”一词虽然是动词,但似乎没有必要保留;第一句话的“吉林大学”是不是更应该作为词组保留下来?第二个问题是:保留词的选择是否和文本领域有关?又应该有一个怎样的标准呢?
将单词数组转换为单词向量。
这里要说的一个概念是“词汇表”,词汇表由所有文本中出现的单词组成。另一个概念是“频数表”,频数表本质上是一个字典,key值为单词本身,value值为该词在整个文本中出现的次数。每一个文本都对应一个属于自己的频数表。
(1)将单词数组转换为单词向量的第一步就是构建词汇表和每个文本的频数表:逐个遍历文本,建立各自的频数表;同时,在建立频数表的同时,将新出现的单词加入词汇表。
(2)将第(1)步得到的频数表转换为频率表。假设频数表 A 的频数总和为 sum ,则其对应的频率表就是将 A 中各个元素(频数)除以 sum 得到频率。
(3)假设在第(1)步得到的词汇表中有 n 个词,那么我们最后要生成的单词向量也是 n 维的。每个文本根据第(2)步得到的频率表来构建单词向量。假设词汇表为{a1,a2,…an},频率表 A 中记录着以下统计量{a2:1/2,an:1/2},那么文本 A 对应的单词向量就是 (0,1/2,0,…0,1/2)
(1)根据单词数组建立频率表和词汇表
/** * @param wordList:单词数组 * @param vocabulary: 词汇表 * @return Map<String,Double>: key为单词,value为频率 * @Description 建立词汇表 wordList 的频率表,并同时建立词汇表 */ public static Map<String,Double> buildFrequencyTable(List<String> wordList,List<String> vocabulary){ // 先建立频数表 Map<String,Integer> countTable = new HashMap<>(); for (String word : wordList) { if(countTable.containsKey(word)){ countTable.put(word,countTable.get(word)+1); } else{ countTable.put(word,1); } // 词汇表中是无重复元素的,所以只在 vocabulary 中没有该元素时才加入 if(!vocabulary.contains(word)){ vocabulary.add(word); } } // totalCount 用于记录词出现的总次数 int totalCount = wordList.size(); // 将频数表转换为频率表 Map<String,Double> frequencyTable = new HashMap<>(); for (String key : countTable.keySet()) { frequencyTable.put(key,(double)countTable.get(key)/totalCount); } return frequencyTable; }
(2)根据频率表得到词向量
/**
* @param frequencyTable : 频率表
* @param wordVector : 转换后的词向量
* @param vocabulary : 词汇表
* @Description 根据词汇表和文本的频率表计算词向量,最后 wordVector 和 vocabulary 应该是同维的
*/
public static void getWordVectorFromFrequencyTable(Map<String,Double> frequencyTable,List<Double> wordVector,List<String> vocabulary){
for (String word : vocabulary) {
double value = 0.0;
if(frequencyTable.containsKey(word)){
value = frequencyTable.get(word);
}
wordVector.add(value);
}
}
(3)综合 (1) (2) 实现将单词数组转换为词向量
/** * @Description : 将单词数组转换为单词向量,结果保存在 vectorA 和 vectorB 里 * @param wordListA : 文本 A 的单词数组 * @param wordListB : 文本 B 的单词数组 * @param vectorA : 文本 A 转换成为的向量 A * @param vectorB : 文本 B 转换成为的向量 B * @return vocabulary : 词汇表 */ public static List<String> convertWordList2Vector(List<String> wordListA,List<String> wordListB,List<Double> vectorA,List<Double> vectorB){ // 词汇表 List<String> vocabulary = new ArrayList<>(); // 获取词汇表 wordListA 的频率表,并同时建立词汇表 Map<String,Double> frequencyTableA = buildFrequencyTable(wordListA, vocabulary); // 获取词汇表 wordListB 的频率表,并同时建立词汇表 Map<String,Double> frequencyTableB = buildFrequencyTable(wordListB, vocabulary); // 根据频率表得到向量 getWordVectorFromFrequencyTable(frequencyTableA,vectorA,vocabulary); getWordVectorFromFrequencyTable(frequencyTableB,vectorB,vocabulary); return vocabulary; }
(4) 简单测试一下函数(3)的效果
基于向量余弦值计算相似度
(1)计算向量平方和开方的函数
// 计算向量平方和的开方
public static double countSquareSum(List<Double> vector){
double result = 0.0;
for (Double value : vector) {
result += value*value;
}
return Math.sqrt(result);
}
(2) 计算两个向量夹角的余弦值
/** * @Description 计算向量 A 和向量 B 的夹角余弦值 * @param vectorA : 词向量 A * @param vectorB : 词向量 B * @return */ public static double countCosine(List<Double> vectorA,List<Double> vectorB){ // 分别计算向量的平方和 double sqrtA = countSquareSum(vectorA); double sqrtB = countSquareSum(vectorB); // 计算向量的点积 double dotProductResult = 0.0; for(int i = 0;i < vectorA.size();i++){ dotProductResult += vectorA.get(i) * vectorB.get(i); } return dotProductResult/(sqrtA*sqrtB); }
因为在3.1节需要将类中许多函数分别测试,所以就把它们都设置成 public 权限类型的了,在完整代码把它们都改回来了,只留下了一个 public 类型的函数供用户直接传入文本得到文本相似性。
package com.test; import com.hankcs.hanlp.HanLP; import com.hankcs.hanlp.corpus.tag.Nature; import com.hankcs.hanlp.seg.common.Term; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @author Llunch4w * @create 2021-04-02 17:52 */ public class MyTextComparator { // 两两对比函数 public static Double getCosineSimilarity(String textA,String textB){ // 从文本中提取出关键词数组 List<String> wordListA = MyTextComparator.extractWordFromText(textA); List<String> wordListB = MyTextComparator.extractWordFromText(textB); List<Double> vectorA = new ArrayList<>(); List<Double> vectorB = new ArrayList<>(); // 将关键词数组转换为词向量并保存在 vectorA 和 vectorB 中 MyTextComparator.convertWordList2Vector(wordListA,wordListB,vectorA,vectorB); // 计算向量夹角的余弦值 double cosine = Double.parseDouble(String.format("%.4f",MyTextComparator.countCosine(vectorA,vectorB))); return cosine; } // 提取文本中有实意的词 private static List<String> extractWordFromText(String text){ // resultList 用于保存提取后的结果 List<String> resultList = new ArrayList<>(); // 当 text 为空字符串时,使用分词函数会报错,所以需要提前处理这种情况 if(text.length() == 0){ return resultList; } // 分词 List<Term> termList = HanLP.segment(text); // 提取所有的 1.名词/n ; 2.动词/v ; 3.形容词/a for (Term term : termList) { if(term.nature == Nature.n || term.nature == Nature.v || term.nature == Nature.a || term.nature == Nature.vn){ resultList.add(term.word); } } return resultList; } /** * @Description : 将单词数组转换为单词向量,结果保存在 vectorA 和 vectorB 里 * @param wordListA : 文本 A 的单词数组 * @param wordListB : 文本 B 的单词数组 * @param vectorA : 文本 A 转换成为的向量 A * @param vectorB : 文本 B 转换成为的向量 B * @return vocabulary : 词汇表 */ private static List<String> convertWordList2Vector(List<String> wordListA,List<String> wordListB,List<Double> vectorA,List<Double> vectorB){ // 词汇表 List<String> vocabulary = new ArrayList<>(); // 获取词汇表 wordListA 的频率表,并同时建立词汇表 Map<String,Double> frequencyTableA = buildFrequencyTable(wordListA, vocabulary); // 获取词汇表 wordListB 的频率表,并同时建立词汇表 Map<String,Double> frequencyTableB = buildFrequencyTable(wordListB, vocabulary); // 根据频率表得到向量 getWordVectorFromFrequencyTable(frequencyTableA,vectorA,vocabulary); getWordVectorFromFrequencyTable(frequencyTableB,vectorB,vocabulary); return vocabulary; } /** * @param wordList:单词数组 * @param vocabulary: 词汇表 * @return Map<String,Double>: key为单词,value为频率 * @Description 建立词汇表 wordList 的频率表,并同时建立词汇表 */ private static Map<String,Double> buildFrequencyTable(List<String> wordList,List<String> vocabulary){ // 先建立频数表 Map<String,Integer> countTable = new HashMap<>(); for (String word : wordList) { if(countTable.containsKey(word)){ countTable.put(word,countTable.get(word)+1); } else{ countTable.put(word,1); } // 词汇表中是无重复元素的,所以只在 vocabulary 中没有该元素时才加入 if(!vocabulary.contains(word)){ vocabulary.add(word); } } // totalCount 用于记录词出现的总次数 int totalCount = wordList.size(); // 将频数表转换为频率表 Map<String,Double> frequencyTable = new HashMap<>(); for (String key : countTable.keySet()) { frequencyTable.put(key,(double)countTable.get(key)/totalCount); } return frequencyTable; } /** * @param frequencyTable : 频率表 * @param wordVector : 转换后的词向量 * @param vocabulary : 词汇表 * @Description 根据词汇表和文本的频率表计算词向量,最后 wordVector 和 vocabulary 应该是同维的 */ private static void getWordVectorFromFrequencyTable(Map<String,Double> frequencyTable,List<Double> wordVector,List<String> vocabulary){ for (String word : vocabulary) { double value = 0.0; if(frequencyTable.containsKey(word)){ value = frequencyTable.get(word); } wordVector.add(value); } } /** * @Description 计算向量 A 和向量 B 的夹角余弦值 * @param vectorA : 词向量 A * @param vectorB : 词向量 B * @return */ private static double countCosine(List<Double> vectorA,List<Double> vectorB){ // 分别计算向量的平方和 double sqrtA = countSquareSum(vectorA); double sqrtB = countSquareSum(vectorB); // 计算向量的点积 double dotProductResult = 0.0; for(int i = 0;i < vectorA.size();i++){ dotProductResult += vectorA.get(i) * vectorB.get(i); } return dotProductResult/(sqrtA*sqrtB); } // 计算向量平方和的开方 private static double countSquareSum(List<Double> vector){ double result = 0.0; for (Double value : vector) { result += value*value; } return Math.sqrt(result); } }
对 MyTextComparator 进行测试:
下图为余弦函数图像,x轴表示角度,y轴表示余弦值。由图可知,夹角定义域在[0,180]时,余弦函数是单调递减的。这是符合“两向量间夹角越大,它们之间越不相似”这一基本前提的。所以,可以使用余弦值作为向量相似度的一个衡量:余弦值越接近于1,两向量相似度越高;反之,余弦值越接近于-1,两向量相似度越低。
基于这个原理,观察我们测试 MyTextComparator 的结果,发现得到的结果还是很符合直观印象的:文本2和其他文本都很不相似,文本0和文本3非常相似。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。