目录
一、前言
在项目开发过程中,遇到了一个需求,要从数量庞大的文本中,计算出文本的相似度并做分类,一般做法都是两两比较相似度,但是这对相似度计算算法的耗时就比较长了,一旦数据量庞大,就能立竿见影。呃,两两相比这个可能不太适合做项目或产品使用,玩玩还可以。如果遇到数量庞大的文本,既要快速且高效的计算出文本的相似度并做分类,如何才能实现呢?这,就是本文博主需要表达的。
通过本文的学习,将很容易理解并实现大量文本之间的相似度计算,在本文中博主通过SimHash算法并结合实际的代码来实现了问帮相似度的计算,当然分类的话就需要根据具体的需求来进行划分了,只要计算出相似度了,剩下的都简单。
二、关于SimHash
SimHash算法是Google在2007年发表的论文《Detecting Near-Duplicates for Web Crawling》中提到的一种指纹生成算法,被应用在Google搜索引擎网页去重的工作之中。SimHash值不但提供了原始值是否相等这一信息,还能通过该值计算出内容的差异程度。
简单的说,SimHash算法主要的工作就是将文本进行降维,生成一个SimHash值,也就是论文中所提及的“指纹”,通过对不同文本的SimHash值进而比较“海明距离”,从而判断两个文本的相似度。
对于文本相似度的问题,常见的解决办法有欧式距离、编辑距离、最长公共子串、余弦算法、Jaccard相似度等方法。但是这些方法并不能对海量数据高效的处理。
比如说,在搜索引擎中,会有很多相似的关键词,用户所需要获取的内容是相似的,但是搜索的关键词却是不同的,例如:
“有哪些是养猫必须知道的冷知识?”
“有那些冷知识是养猫必须知道的?”
以上两个可以等价的关键词,然而通过普通的hash计算,会产生两个相差甚远的hash串。而通过SimHash计算得到的Hash串会非常的相近,从而可以判断两个文本的相似程度。
补充知识
一)、什么是海明距离
海明距离是编辑距离其中之一,在信息编码中,两个合法代码对应位上编码不同的位数称为码距,又称海明距离。即两个码字的对应比特取值不同的比特数称为这两个码字的海明距离。一个有效编码集中任意两个码字的海明距离的最小值称为该编码集的海明距离。例如: 10101 和 00110 从第一位开始依次有第一位、第四、第五位不同,则海明距离为 3。
N位的码字可以用N维空间的超立方体的一个顶点来表示,两个码字之间的海明距离就是超立方体两个顶点之间的一条边,而且是这两个顶点之间的最短距离。
二)、海明距离的应用
海明距离应用最多的是在海量短文、文本去重上,以其性能优的特点。海明距离主要就是对文本进行向量化,或者说是把文本的特征抽取出来映射成编码,然后再对编码进行异或计算出海明距离。
三)、什么是编辑距离
编辑距离又称Levenshtein距离(也叫做Edit Distance),是指两个字串之间,由一个转成另一个所需的最少编辑操作次数,许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。
三、SimHash算法的几何意义和原理
一)、SimHash算法的几何意义
它首先将每一个特征映射为f维空间的一个向量,这个映射规则具体是怎样并不重要,只要对很多不同的特征来说,它们对所对应的向量是均匀随机分布的,并且对相同的特征来说对应的向量是唯一的就行。比如一个特征的4位Hash签名的二进制表示为1010,那么这个特征对应的4维向量就是(1,-1,1,-1)T,即Hash签名的某一位为1,映射到的向量的对应位就为1,否则为-1。然后,将一个文档中所包含的各个特征对应的向量加权求和,加权的系数等于该特征的权重。得到的和向量即表征了这个文档,我们可以用向量之间的夹角来衡量对应文档之间的相似度。最后,为了得到一个f位的签名,需要进一步将其压缩,如果和向量的某一维大于0,则最终签名的对应位为1,否则为0。这样的压缩相当于只留下了和向量所在的象限这个信息,而64位的签名可以表示多达264个象限,因此只保存所在象限的信息也足够表征一个文档了。
明确了SimHash算法的几何意义,使这个算法直观上看来是合理的。但是,为何最终得到的签名相近的程度,即可以衡量原始文档的相似程度呢。在SimHash的发明人Charikar的论文中并没有给出具体的SimHash算法和证明,以下列出证明思路:
SimHash是由随机超平面Hash算法演变而来的,随机超平面Hash算法非常简单,对于一个n维向量v,要得到一个f位的签名(f<<n),算法如下:
1、随机产生f个n维的向量r1,…rf;
2、对每一个向量ri,如果v与ri的点积大于0,则最终签名的第i位为1,否则为0;
这个算法相当于随机产生了f个n维超平面,每个超平面将向量v所在的空间一分为二,v在这个超平面上方则得到一个1,否则得到一个0,然后将得到的 f个0或1组合起来成为一个f维的签名。如果两个向量u,v的夹角为θ,则一个随机超平面将它们分开的概率为θ/π,因此u, v的签名的对应位不同的概率等于θ/π。所以,我们可以用两个向量的签名的不同的对应位的数量,即海明距离,来衡量这两个向量的差异程度。
SimHash算法与随机超平面Hash如何联系起来?在SimHash算法中,并没有直接产生用于分割空间的随机向量,而是间接产生->第 k个特征的Hash签名的第i位拿出来,如果为0,则改为-1,如果为1则不变,作为第i个随机向量的第k维。由于hash签名是f位的,因此这样能产生 f个随机向量,对应f个随机超平面。下面举个例子:
假设用5个特征w1,…,w5来表示所有文档,现要得到任意文档的一个3维签名。
假设这5个特征对应的3维向量分别为:
h(w1) = (1, -1, 1)T
h(w2) = (-1, 1, 1)T
h(w3) = (1, -1, -1)T
h(w4) = (-1, -1, 1)T
h(w5) = (1, 1, -1)T
按SimHash算法,要得到一个文档向量
d=(w1=1, w2=2, w3=0, w4=3, w5=0) T的签名,先要计算向量
m = 1*h(w1) + 2*h(w2) + 0*h(w3) + 3*h(w4) + 0*h(w5) = (-4, -2, 6) T,
然后根据SimHash算法的步骤3,得到最终的签名s=001。
上面的计算步骤其实相当于->先得到3个5维的向量:
第1个5维向量由h(w1),…,h(w5)的第1维组成:r1=(1,-1,1,-1,1) T;
第2个5维向量由h(w1),…,h(w5)的第2维组成:r2=(-1,1,-1,-1,1) T;
第3个5维向量由h(w1),…,h(w5)的第3维组成:r3=(1,1,-1,1,-1) T;
按随机超平面算法的步骤2,分别求向量d与r1,r2,r3的点积:
d T r1=-4 < 0,所以s1=0;
d T r2=-2 < 0,所以s2=0;
d T r3=6 > 0,所以s3=1;
故最终的签名s=001,与SimHash算法产生的结果是一致的。
从上面的计算过程可以看出,SimHash算法其实与随机超平面Hash算法是相同的,SimHash算法得到的两个签名的海明距离,可以用来衡量原始向量的夹角。这其实是一种降维技术,将高维的向量用较低维度的签名来表征。衡量两个内容相似度,需要计算海明距离,这对给定签名查找相似内容的应用来说带来了一些计算上的困难。
SimHash算法的输入是一个向量,输出是一个 f 位的签名值。为了表述方便,假设输入的是一个文档的特征集合,每个特征有一定的权重,比如特征可以是文档中的词,其权重可以是这个词出现的次数。
二)、SimHash的计算原理
SimHash算法主要分为五个过程:分词、Hash、加权、合并、降维。如图实例[图示-01]
1、分词
对给定的一段文本进行分词,产生n个特征词,并赋予每个特征词一个权重。
2.Hash
通过hash函数对每一个词向量进行映射,产生一个n位二进制串。
3.加权
经过前面的计算已经得到了每个词向量的Hash串和该词向量对应的权重,第三步计算权重向量W=hash*weight。
4.合并
对于一个文本,计算出了文本分词之后每一个特征词的权重向量,在合并这个阶段,把文本所有词向量的权重向量相累加,得到一个新的权重向量。
5.降维
对于前面合并后得到的文本的权重向量,大于0的位置1,小于等于0的位置0,就可以得到该文本的SimHash值。
SimHash算法步骤如下:
1、将一个 f 维的向量 V 初始化为 0 ;f 位的二进制数 S 初始化为 0;
2、对每一个特征用传统的 Hash 算法对该特征产生一个 f 位的签名 b。对 i=1 到 f :
如果b 的第 i 位为 1 ,则 V 的第 i 个元素加上该特征的权重;
否则,V 的第 i 个元素减去该特征的权重;
3、如果 V 的第 i 个元素大于 0 ,则 S 的第 i 位为 1,否则为 0 ;
4,输出 S 作为签名;
Simhash算法原理图如下:
三)、文本的相似度计算
对每条文本根据SimHash 算出签名后,再计算两个签名的海明距离(两个二进制异或后1的个数)即可。根据经验值,对64位的SimHash,海明距离在3以内的可以认为相似度比较高。
假设对64位的SimHash,查找海明距离在3以内的所有签名。可以把64位的二进制签名均分成4块,每块16位。根据鸽巢原理(也称抽屉原理,见组合数学),如果两个签名的海明距离在3以内,它们必有一块完全相同。
把上面分成的4块中的每一个块分别作为前16位来进行查找。建立倒排索引。
相似度算法原理图如下:
如果库中有2^34个(大概10亿)签名,那么匹配上每个块的结果最多有2^(34-16)=262144个候选结果(假设数据是均匀分布,16位的数据,产生的像限为2^16个,则平均每个像限分布的文档数则2^34/2^16=2^(34-16)),四个块返回的总结果数为4*262144(大概100万)。原本需要比较10亿次,经过索引,大概就只需要处理100万次了。由此可见,确实大大减少了计算量。、
四、Java通过SimHash计算文本内容相似度代码示例
一)、新增依赖包
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.11.3</version>
</dependency>
<dependency>
<groupId>com.hankcs</groupId>
<artifactId>hanlp</artifactId>
<version>portable-1.8.1</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
二)、过滤特殊字符
/**
* Description:[过滤特殊字符]
*
* @return BigInteger
* @date 2020-04-01
* @author huazai
*/
private String clearSpecialCharacters(String topicName) {
// 将内容转换为小写
topicName = StringUtils.lowerCase(topicName);
// 过来HTML标签
topicName = Jsoup.clean(topicName, Whitelist.none());
// 过滤特殊字符
String[] strings = {" ", "\n", "\r", "\t", "\\r", "\\n", "\\t", " ", "&", "<", ">", """, "&qpos;"};
for (String string : strings) {
topicName = topicName.replaceAll(string, "");
}
return topicName;
}
三)、计算单个分词的Hash值
/**
* Description:[计算单个分词的hash值]
*
* @return BigInteger
* @date 2020-04-01
* @author huazai
*/
private BigInteger getWordHash(String word) {
if (StringUtils.isEmpty(word)) {
// 如果分词为null,则默认hash为0
return new BigInteger("0");
} else {
// 分词补位,如果过短会导致Hash算法失败
while (word.length() < SimHashUtil.WORD_MIN_LENGTH) {
word = word + word.charAt(0);
}
// 分词位运算
char[] wordArray = word.toCharArray();
BigInteger x = BigInteger.valueOf(wordArray[0] << 7);
BigInteger m = new BigInteger("1000003");
// 初始桶pow运算
BigInteger mask = new BigInteger("2").pow(this.hashCount).subtract(new BigInteger("1"));
for (char item : wordArray) {
BigInteger temp = BigInteger.valueOf(item);
x = x.multiply(m).xor(temp).and(mask);
}
x = x.xor(new BigInteger(String.valueOf(word.length())));
if (x.equals(ILLEGAL_X)) {
x = new BigInteger("-2");
}
return x;
}
}
四)、分词计算向量
/**
* Description:[分词计算向量]
*
* @return BigInteger
* @date 2020-04-01
* @author huazai
*/
private BigInteger simHash() {
// 清除特殊字符
this.topicName = this.clearSpecialCharacters(this.topicName);
int[] hashArray = new int[this.hashCount];
// 对内容进行分词处理
List<Term> terms = StandardTokenizer.segment(this.topicName);
// 配置词性权重
Map<String, Integer> weightMap = new HashMap<>(16, 0.75F);
weightMap.put("n", 1);
// 设置停用词
Map<String, String> stopMap = new HashMap<>(16, 0.75F);
stopMap.put("w", "");
// 设置超频词上线
Integer overCount = 5;
// 设置分词统计量
Map<String, Integer> wordMap = new HashMap<>(16, 0.75F);
for (Term term : terms) {
// 获取分词字符串
String word = term.word;
// 获取分词词性
String nature = term.nature.toString();
// 过滤超频词
if (wordMap.containsKey(word)) {
Integer count = wordMap.get(word);
if (count > overCount) {
continue;
} else {
wordMap.put(word, count + 1);
}
} else {
wordMap.put(word, 1);
}
// 过滤停用词
if (stopMap.containsKey(nature)) {
continue;
}
// 计算单个分词的Hash值
BigInteger wordHash = this.getWordHash(word);
for (int i = 0; i < this.hashCount; i++) {
// 向量位移
BigInteger bitMask = new BigInteger("1").shiftLeft(i);
// 对每个分词hash后的列进行判断,例如:1000...1,则数组的第一位和末尾一位加1,中间的62位减一,也就是,逢1加1,逢0减1,一直到把所有的分词hash列全部判断完
// 设置初始权重
Integer weight = 1;
if (weightMap.containsKey(nature)) {
weight = weightMap.get(nature);
}
// 计算所有分词的向量
if (wordHash.and(bitMask).signum() != 0) {
hashArray[i] += weight;
} else {
hashArray[i] -= weight;
}
}
}
// 生成指纹
BigInteger fingerPrint = new BigInteger("0");
for (int i = 0; i < this.hashCount; i++) {
if (hashArray[i] >= 0) {
fingerPrint = fingerPrint.add(new BigInteger("1").shiftLeft(i));
}
}
return fingerPrint;
}
五)、获取标题内容的海明距离
/**
* Description:[获取标题内容的海明距离]
*
* @return Double
* @date 2020-04-01
* @author huazai
*/
private int getHammingDistance(SimHashUtil simHashUtil) {
// 求差集
BigInteger subtract = new BigInteger("1").shiftLeft(this.hashCount).subtract(new BigInteger("1"));
// 求异或
BigInteger xor = this.bigSimHash.xor(simHashUtil.bigSimHash).and(subtract);
int total = 0;
while (xor.signum() != 0) {
total += 1;
xor = xor.and(xor.subtract(new BigInteger("1")));
}
return total;
}
六)、获取标题内容的相似度
/**
* Description:[获取标题内容的相似度]
*
* @return Double
* @date 2020-04-01
* @author huazai
*/
public Double getSimilar(SimHashUtil simHashUtil) {
// 获取海明距离
Double hammingDistance = (double) this.getHammingDistance(simHashUtil);
// 求得海明距离百分比
Double scale = (1 - hammingDistance / this.hashCount) * 100;
Double formatScale = Double.parseDouble(String.format("%.2f", scale));
return formatScale;
}
七)、测试
public static void main(String[] args) {
// 准备测试标题内容数据
List<String> titleList = new ArrayList<>();
titleList.add("有哪些养猫必须知道的冷知识");
titleList.add("有哪些养猫必须知道的冷");
titleList.add("有哪些养猫必须知道");
titleList.add("有哪些养猫");
titleList.add("有哪些");
// 原始标题内容数据
String originalTitle = "有哪些养猫必须知道的冷知识?";
Map<String, Double> simHashMap = new HashMap<>(16, 0.75F);
System.out.println("======================================");
long startTime = System.currentTimeMillis();
System.out.println("原始标题:" + originalTitle);
// 计算相似度
titleList.forEach(title -> {
SimHashUtil mySimHash_1 = new SimHashUtil(title, 64);
SimHashUtil mySimHash_2 = new SimHashUtil(originalTitle, 64);
Double similar = mySimHash_1.getSimilar(mySimHash_2);
simHashMap.put(title, similar);
});
// 打印测试结果到控制台
/* simHashMap.forEach((title, similarity) -> {
System.out.println("标题:"+title+"-----------相似度:"+similarity);
});*/
// 按相标题内容排序输出控制台
Set<String> titleSet = simHashMap.keySet();
Object[] titleArrays = titleSet.toArray();
Arrays.sort(titleArrays, Collections.reverseOrder());
System.out.println("-------------------------------------");
for (Object title : titleArrays) {
System.out.println("标题:" + title + "-----------相似度:" + simHashMap.get(title));
}
// 求得运算时长(单位:毫秒)
long endTime = System.currentTimeMillis();
long totalTime = endTime - startTime;
System.out.println("\n本次运算总耗时" + totalTime + "毫秒");
System.out.println("======================================");
}
八)、示例效果图
九)、完整代码示例
package com.b2c.aiyou.bbs.common.utils.hanlp;
import com.hankcs.hanlp.seg.common.Term;
import com.hankcs.hanlp.tokenizer.StandardTokenizer;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.Jsoup;
import org.jsoup.safety.Whitelist;
import java.math.BigInteger;
import java.util.*;
/**
* System: BBS论坛系统
* Department: 研发一组
* Title: [aiyou-bbs — SimHashUtil 模块]
* Description: [SimHash 标题内容相似度算法工具类]
* Created on: 2020-04-01
* Contacts: [who.seek.me@java98k.vip]
*
* @author huazai
* @version V1.1.0
*/
public class SimHashUtil {
/**
* 标题名称
*/
private String topicName;
/**
* 分词向量
*/
private BigInteger bigSimHash;
/**
* 初始桶大小
*/
private Integer hashCount = 64;
/**
* 分词最小长度限制
*/
private static final Integer WORD_MIN_LENGTH = 3;
private static final BigInteger ILLEGAL_X = new BigInteger("-1");
public SimHashUtil(String topicName, Integer hashCount) {
this.topicName = topicName;
this.bigSimHash = this.simHash();
this.hashCount = hashCount;
}
/**
* Description:[分词计算向量]
*
* @return BigInteger
* @date 2020-04-01
* @author huazai
*/
private BigInteger simHash() {
// 清除特殊字符
this.topicName = this.clearSpecialCharacters(this.topicName);
int[] hashArray = new int[this.hashCount];
// 对内容进行分词处理
List<Term> terms = StandardTokenizer.segment(this.topicName);
// 配置词性权重
Map<String, Integer> weightMap = new HashMap<>(16, 0.75F);
weightMap.put("n", 1);
// 设置停用词
Map<String, String> stopMap = new HashMap<>(16, 0.75F);
stopMap.put("w", "");
// 设置超频词上线
Integer overCount = 5;
// 设置分词统计量
Map<String, Integer> wordMap = new HashMap<>(16, 0.75F);
for (Term term : terms) {
// 获取分词字符串
String word = term.word;
// 获取分词词性
String nature = term.nature.toString();
// 过滤超频词
if (wordMap.containsKey(word)) {
Integer count = wordMap.get(word);
if (count > overCount) {
continue;
} else {
wordMap.put(word, count + 1);
}
} else {
wordMap.put(word, 1);
}
// 过滤停用词
if (stopMap.containsKey(nature)) {
continue;
}
// 计算单个分词的Hash值
BigInteger wordHash = this.getWordHash(word);
for (int i = 0; i < this.hashCount; i++) {
// 向量位移
BigInteger bitMask = new BigInteger("1").shiftLeft(i);
// 对每个分词hash后的列进行判断,例如:1000...1,则数组的第一位和末尾一位加1,中间的62位减一,也就是,逢1加1,逢0减1,一直到把所有的分词hash列全部判断完
// 设置初始权重
Integer weight = 1;
if (weightMap.containsKey(nature)) {
weight = weightMap.get(nature);
}
// 计算所有分词的向量
if (wordHash.and(bitMask).signum() != 0) {
hashArray[i] += weight;
} else {
hashArray[i] -= weight;
}
}
}
// 生成指纹
BigInteger fingerPrint = new BigInteger("0");
for (int i = 0; i < this.hashCount; i++) {
if (hashArray[i] >= 0) {
fingerPrint = fingerPrint.add(new BigInteger("1").shiftLeft(i));
}
}
return fingerPrint;
}
/**
* Description:[计算单个分词的hash值]
*
* @return BigInteger
* @date 2020-04-01
* @author huazai
*/
private BigInteger getWordHash(String word) {
if (StringUtils.isEmpty(word)) {
// 如果分词为null,则默认hash为0
return new BigInteger("0");
} else {
// 分词补位,如果过短会导致Hash算法失败
while (word.length() < SimHashUtil.WORD_MIN_LENGTH) {
word = word + word.charAt(0);
}
// 分词位运算
char[] wordArray = word.toCharArray();
BigInteger x = BigInteger.valueOf(wordArray[0] << 7);
BigInteger m = new BigInteger("1000003");
// 初始桶pow运算
BigInteger mask = new BigInteger("2").pow(this.hashCount).subtract(new BigInteger("1"));
for (char item : wordArray) {
BigInteger temp = BigInteger.valueOf(item);
x = x.multiply(m).xor(temp).and(mask);
}
x = x.xor(new BigInteger(String.valueOf(word.length())));
if (x.equals(ILLEGAL_X)) {
x = new BigInteger("-2");
}
return x;
}
}
/**
* Description:[过滤特殊字符]
*
* @return BigInteger
* @date 2020-04-01
* @author huazai
*/
private String clearSpecialCharacters(String topicName) {
// 将内容转换为小写
topicName = StringUtils.lowerCase(topicName);
// 过来HTML标签
topicName = Jsoup.clean(topicName, Whitelist.none());
// 过滤特殊字符
String[] strings = {" ", "\n", "\r", "\t", "\\r", "\\n", "\\t", " ", "&", "<", ">", """, "&qpos;"};
for (String string : strings) {
topicName = topicName.replaceAll(string, "");
}
return topicName;
}
/**
* Description:[获取标题内容的相似度]
*
* @return Double
* @date 2020-04-01
* @author huazai
*/
public Double getSimilar(SimHashUtil simHashUtil) {
// 获取海明距离
Double hammingDistance = (double) this.getHammingDistance(simHashUtil);
// 求得海明距离百分比
Double scale = (1 - hammingDistance / this.hashCount) * 100;
Double formatScale = Double.parseDouble(String.format("%.2f", scale));
return formatScale;
}
/**
* Description:[获取标题内容的海明距离]
*
* @return Double
* @date 2020-04-01
* @author huazai
*/
private int getHammingDistance(SimHashUtil simHashUtil) {
// 求差集
BigInteger subtract = new BigInteger("1").shiftLeft(this.hashCount).subtract(new BigInteger("1"));
// 求异或
BigInteger xor = this.bigSimHash.xor(simHashUtil.bigSimHash).and(subtract);
int total = 0;
while (xor.signum() != 0) {
total += 1;
xor = xor.and(xor.subtract(new BigInteger("1")));
}
return total;
}
}
五、结束语
关于SimHash算法计算文本相似问题就写到这儿了,当然,有兴趣伙伴,还可以试下余弦算法、欧式距离、海明距离三者在文本相似度计算方面的性能,
测试性能例举:
10W文本的计算性能值比;
1000W文本的计算性能值比;
3000W文本的计算性能值比;
5000W文本的计算性能值比;
参考文献:
好了,关于 Java实现标题相似度计算,文本内容相似度匹配,Java通过SimHash计算标题文本内容相似度 就写到这儿了,如果还有什么疑问或遇到什么问题欢迎扫码提问,也可以给我留言哦,我会一一详细的解答的。
歇后语:“ 共同学习,共同进步 ”,也希望大家多多关注CSND的IT社区。
作 者: | 华 仔 |
联系作者: | who.seek.me@java98k.vip |
来 源: | CSDN (Chinese Software Developer Network) |
原 文: | https://blog.csdn.net/Hello_World_QWP/article/details/122842339 |
版权声明: | 本文为博主原创文章,请在转载时务必注明博文出处! |