在做考试系统需求时,后台题库系统提供录入题目的功能。在录入题目的时候,由于题目来源广泛,且参与录入题目的人有多位,因此容易出现录入重复题目的情况。所以需要实现语句相似度分析功能,从而筛选出重复的题目并人工处理之。
分析语句相似度思想的具体介绍,参考阮一峰【TF-IDF与余弦相似性的应用:找出相似文章】http://www.ruanyifeng.com/blog/2013/03/cosine_similarity.html
一、分析
下面介绍如何使用Java实现上述想法,完成语句相似度分析。
2、HanLP 是什么?
使用 HanLP - 汉语言处理包 来处理,他能处理很多事情,如分词、调用分词器、命名实体识别、人名识别、地名识别、词性识别、篇章理解、关键词提取、简繁拼音转换、拼音转换、根据输入智能推荐、自定义分词器
GitHub 文档:https://github.com/hankcs/HanLP/tree/1.x
GitHub Demo:https://github.com/hankcs/HanLP/blob/1.x/src/test/java/com/hankcs/demo
HanLP 2.0 Alpha发布视频:https://www.bilibili.com/video/av81733438
2、余弦相似性是什么?
为了找出相似的文章,需要用到"余弦相似性"(cosine similiarity)。下面,我举一个例子来说明,什么是"余弦相似性"。
为了简单起见,我们先从句子着手。
句子A:我喜欢看电视,不喜欢看电影。 句子B:我不喜欢看电视,也不喜欢看电影。
请问怎样才能计算上面两句话的相似程度?
基本思路是:如果这两句话的用词越相似,它们的内容就应该越相似
。因此,可以从词频入手,计算它们的相似程度。
第一步,分词。
句子A:我/喜欢/看/电视,不/喜欢/看/电影。 句子B:我/不/喜欢/看/电视,也/不/喜欢/看/电影。
第二步,列出所有的词。
我,喜欢,看,电视,电影,不,也。
第三步,计算词频。
句子A:我 1,喜欢 2,看 2,电视 1,电影 1,不 1,也 0。 句子B:我 1,喜欢 2,看 2,电视 1,电影 1,不 2,也 1。
第四步,写出词频向量。
句子A:[1, 2, 2, 1, 1, 1, 0] 句子B:[1, 2, 2, 1, 1, 2, 1]
到这里,问题就变成了如何计算这两个向量的相似程度。
我们可以把它们想象成空间中的两条线段,都是从原点([0, 0, ...])出发,指向不同的方向。两条线段之间形成一个夹角。
如果夹角为0度,意味着方向相同、线段重合;
如果夹角为90度,意味着形成直角,方向完全不相似;
如果夹角为180度,意味着方向正好相反。
因此,我们可以通过夹角的大小,来判断向量的相似程度。夹角越小,就代表越相似。
以二维空间为例,上图的 a和b 是两个向量,我们要计算它们的夹角 θ。余弦定理告诉我们,可以用下面的公式求得:
假定 a向量是 [x1, y1],b向量是 [x2, y2],那么可以将余弦定理改写成下面的形式:
数学家已经证明,余弦的这种计算方法对 n维向量也成立。假定A和B是两个n维向量,A是 [A1, A2, ..., An] ,B是 [B1, B2, ..., Bn] ,则A与B的夹角θ的余弦等于:
使用这个公式,我们就可以得到,句子A与句子B的夹角的余弦。
余弦值越接近1,就表明夹角越接近0度,也就是两个向量越相似,这就叫"余弦相似性"。
所以,上面的句子A和句子B是很相似的,事实上它们的夹角大约为20.3度。
由此,我们就得到了"找出相似文章"的一种算法:
(1)使用TF-IDF算法,找出两篇文章的关键词; (2)每篇文章各取出若干个关键词(比如20个),合并成一个集合,计算每篇文章对于这个集合中的词的词频(为了避免文章长度的差异,可以使用相对词频); (3)生成两篇文章各自的词频向量; (4)计算两个向量的余弦相似度,值越大就表示越相似。
"余弦相似度"是一种非常有用的算法,只要是计算两个向量的相似程度,都可以采用它。
二、操作代码
1、引入 HanLP 依赖包
<dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>portable-1.7.8</version> </dependency>
2、工具类
import com.hankcs.hanlp.HanLP; import com.hankcs.hanlp.dictionary.CustomDictionary; import java.util.*; import java.util.stream.Collectors; public class SimilarityUtil { private static final String FILTER_TERMS = "`~!@#$^&*()=|{}':;',\\[\\].<>/?~!@#¥……&*()——|{}【】‘;:”“'。,、?"; static { // HanLP 用户自定义词典 CustomDictionary.add("子类"); CustomDictionary.add("父类"); } private SimilarityUtil() { } /** * 相似性比较 * @param sentence1 句子1 * @param sentence2 句子2 * @return */ public static double getSimilarity(String sentence1, String sentence2) { List<String> sent1Words = getSplitWords(sentence1); List<String> sent2Words = getSplitWords(sentence2); List<String> allWords = mergeList(sent1Words, sent2Words); int[] statistic1 = statistic(allWords, sent1Words); int[] statistic2 = statistic(allWords, sent2Words); double dividend = 0; double divisor1 = 0; double divisor2 = 0; for (int i = 0; i < statistic1.length; i++) { dividend += statistic1[i] * statistic2[i]; divisor1 += Math.pow(statistic1[i], 2); divisor2 += Math.pow(statistic2[i], 2); } return dividend / (Math.sqrt(divisor1) * Math.sqrt(divisor2)); } /** * 词频分析 * * @param allWords 去重后所有词列表 * @param sentWords 分析词列表 * @return */ private static int[] statistic(List<String> allWords, List<String> sentWords) { int[] result = new int[allWords.size()]; for (int i = 0; i < allWords.size(); i++) { result[i] = Collections.frequency(sentWords, allWords.get(i)); } return result; } /** * 合并两个分词结果(去重) */ private static List<String> mergeList(List<String> list1, List<String> list2) { List<String> result = new ArrayList<>(); result.addAll(list1); result.addAll(list2); return result.stream().distinct().collect(Collectors.toList()); } /** * 分词(过滤特殊字符的词) */ private static List<String> getSplitWords(String sentence) { return HanLP.segment(sentence).stream().map(a -> a.word).filter(s -> !FILTER_TERMS.contains(s)).collect(Collectors.toList()); } }
3、测试
public static void main(String[] args) { String s1 = "子类可以覆盖父类的成员方法,也可以覆盖父类的成员变量"; String s2 = "子类不可以覆盖父类的成员方法,也不可以覆盖父类的成员变量"; System.out.println("\"" + s1 + "\"" + "与" + "\"" + s2 + "的相似度是:" + SimilarityUtil.getSimilarity(s1, s2)); s1 = "世界你好"; s2 = "你好世界"; System.out.println("\"" + s1 + "\"" + "与" + "\"" + s2 + "的相似度是:" + SimilarityUtil.getSimilarity(s1, s2)); s1 = "买鞋子不能去文具店"; s2 = "文具店不卖鞋子"; System.out.println("\"" + s1 + "\"" + "与" + "\"" + s2 + "的相似度是:" + SimilarityUtil.getSimilarity(s1, s2)); }
4、测试结果
"子类可以覆盖父类的成员方法,也可以覆盖父类的成员变量"与"子类不可以覆盖父类的成员方法,也不可以覆盖父类的成员变量的相似度是:0.9258200997725514 "世界你好"与"你好世界的相似度是:0.9999999999999998 "买鞋子不能去文具店"与"文具店不卖鞋子的相似度是:0.4472135954999579
前两个相似度很高,最后一个相似性来看略低,而实际上,两句话的含义是相同的,如果需要进一步分析,就需要引入智能分析的概念了。
三、参考文档
余弦定律:http://zh.wikipedia.org/zh-cn/餘弦定理
余弦相似性:https://en.wikipedia.org/wiki/Cosine_similarity
未经允许请勿转载:程序喵 » Java 利用 HanLP 完成语句相似度分析