Java 利用 HanLP 完成语句相似度分析

u=2498370674,4266870023&fm=193.jpeg

在做考试系统需求时,后台题库系统提供录入题目的功能。在录入题目的时候,由于题目来源广泛,且参与录入题目的人有多位,因此容易出现录入重复题目的情况。所以需要实现语句相似度分析功能,从而筛选出重复的题目并人工处理之。

分析语句相似度思想的具体介绍,参考阮一峰【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度,意味着方向正好相反。

因此,我们可以通过夹角的大小,来判断向量的相似程度。夹角越小,就代表越相似

bg2013032002.png

以二维空间为例,上图的 a和b 是两个向量,我们要计算它们的夹角 θ。余弦定理告诉我们,可以用下面的公式求得:

假定 a向量是 [x1, y1],b向量是 [x2, y2],那么可以将余弦定理改写成下面的形式:

bg2013032006.png

bg2013032005.png

数学家已经证明,余弦的这种计算方法对 n维向量也成立。假定A和B是两个n维向量,A是 [A1, A2, ..., An] ,B是 [B1, B2, ..., Bn] ,则A与B的夹角θ的余弦等于:

bg2013032007.png

使用这个公式,我们就可以得到,句子A与句子B的夹角的余弦。

bg2013032008.png

余弦值越接近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 完成语句相似度分析

点  赞 (1) 打  赏
分享到: