基于Java的文本相似度计算

1. 前言

最近在做一个基于SSM的Web项目,其中有一项功能是 对相似文本进行合并 ,其中涉及一个文本间相似度计算的问题。在此将实现过程记录下来。

1.1 开发环境:

名称版本
操作系统Win10 X64
JDK1.8.0_144
InteIliJ IDEA2020.1
Tomcat9.0.29

1.2 初步设想

开始前有三个技术问题待解决 :

  1. 第一个问题是如何衡量文本的相似性。拟采用余弦相似性的方法。
  2. 余弦相似度的计算单元是向量,因此第二个问题是如何将文本转换为向量。
  3. 第三个问题是如何将一对一比较转换为多对多比较,按相似度将语料库中的多个文本进行相似度聚类。(这个问题并没有得到解决)

1.3 参考资料

  1. CSDN博客 – Java 实现计算文本相似度 (使用余弦定理) : 本文许多代码是参考这篇博客里的。这篇博客的内容感觉有好多篇与它类似,我也不知道找的是不是原版…
  2. CSDN博客 – JAVA-简单实现文本相似度计算-余弦相似度 : 这篇博文提供了一个不使用 HanLP 库进行分词的简单案例,是基于字进行分割的。

2. HanLP

Han Language Processing 是一个自然语言处理工具包,基于PyTorch和TensorFlow 2.x双引擎实现。可以在多种语言环境下引入HanLP包,利用其中封装好的API进行快捷的NLP开发。

2.1 在Java中使用HanLP库

我构建的是一个 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 应用(甚至是整台电脑)的反应时间。

后来这个方法对电脑运行的拖累实在是太大了,于是笔者不得已找了另一个方法。系统提示找不到包,那么直接包缺少的包下载下来引入就行了。步骤如下:

  1. 前往下载 hanlp 的 jar 包,网址:https://github.com/hankcs/HanLP/releases
    在这里插入图片描述
  2. 将下载好的 jar 包放入项目的 lib 文件夹下(这个 lib 文件夹是笔者自己建的),直接将 jar 包从下载好的压缩包解压得到的文件夹中拖入 IDEA 中即可。
    在这里插入图片描述
  3. 通过右键项目 Open Module Settings,或者 File -> Project Structure 打开 1 所示窗口,按如下步骤进行操作,将 lib 中的 jar 包导入项目。
    在这里插入图片描述
    参考解答:IDEA-idea中解决Java程序包不存在问题

2.2 分词函数

利用 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 + ",");
    }
}

得到结果如下:
在这里插入图片描述

  • HanLP 的 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语气词如:啊,诶

3. 双文本对比

3.1 步骤分解

  1. 假设有两个文本 A 和 B,计算它们的相似程度。不妨建立一个工具类来做这个工作。

    public class MyTextComparator{}
    
  2. 第一步是将一个文本转换为一组词序列,这些词应该是有实际意义的。这里,我取了名词、动词、形容词、动名词四种词进行保留。

    // 提取文本中有实意的词
    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;
    }
    

    得到结果如下:
    在这里插入图片描述

    看到这个结果,自然而然想到的一个问题是:究竟什么样的词更应该被保留下来?比如:第二句话的“认为”一词虽然是动词,但似乎没有必要保留;第一句话的“吉林大学”是不是更应该作为词组保留下来?第二个问题是:保留词的选择是否和文本领域有关?又应该有一个怎样的标准呢?

  3. 将单词数组转换为单词向量。

    这里要说的一个概念是“词汇表”,词汇表由所有文本中出现的单词组成。另一个概念是“频数表”,频数表本质上是一个字典,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)的效果
    在这里插入图片描述

  4. 基于向量余弦值计算相似度
    在这里插入图片描述
    (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.2 完整代码

因为在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非常相似。

  • 14
    点赞
  • 75
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值