詞義類似度計算在不少領域中都有普遍的應用,例如信息檢索、信息抽取、文本分類、詞義排歧、基於實例的機器翻譯等等。國內目前主要是使用知網和同義詞詞林來進行詞語的類似度計算。java
本文主要是根據《基於同義詞詞林的詞語類似度計算方法—田久樂》論文中所提出的分層算法實現類似度計算,程序採用Java語言編寫。git
《同義詞詞林》是梅家駒等人於1983年編纂而成,這本詞典中不只包括了一個詞語的同義詞,也包含了必定數量的同類詞,即廣義的相關詞。《同義詞詞林擴展版》是由哈爾濱工業大學信息檢索實驗室所從新修訂所得。該版收錄詞語近7萬條,所有按意義進行編排,是一部同義類詞典。github
同義詞詞林按照樹狀的層次結構把全部收錄的詞條組織到一塊兒,把詞彙分紅大、中、小三類,大類有12個,中類有97個,小類有1400個。每一個小類裏都有不少的詞,這些詞又根據詞義的遠近和相關性分紅了若干個詞羣(段落)。每一個段落中的詞語又進一步分紅了若干個行,同一行的詞語要麼詞義相同,要麼詞義有很強的相關性。web
《同義詞詞林》提供了5層編碼,第1級用大寫英文字母表示;第2級用小寫英文字母表示;第3級用二位十進制整數表示;第4級用大寫英文字母表示;第5級用二位十進制整數表示。例如:
Cb30A01= 這裏 這邊 此地 此間 此處 此
Cb30A02# 該地 該鎮 該鄉 該站 該區 該市 該村
Cb30A03@ 這方算法
因爲第5級有的行是同義詞,有的行是相關詞,有的行只有一個詞,分類結果須要特別說明,能夠分出具體的3種狀況。使用特殊符號對這3種狀況進行區別對待,因此第8位的標記有3種,分別是「=」表明「相等」、「同義」;「#」表明「不等」、「同類」,屬於相關詞語;「@」表明「自我封閉」、「獨立」,它在詞典中既沒有同義詞,也沒有相關詞。app
在對文本內容進行類似度計算中,採用該論文中給出的計算公式,兩個義項的類似度用Sim
表示
若兩個義項不在同一棵樹上,Sim(A,B)=f
若兩個義項在同一棵樹上:
若在第2層分支,係數爲a Sim(A,B)=1*a*cos*(n*π/180)((n-k+1)/n)
若在第3層分支,係數爲b Sim(A,B)=1*1*b*cos*(n*π/180)((n-k+1)/n)
若在第4層分支,係數爲c Sim(A,B)=1*1*1*c×cos*(n*π/180)((n-k+1)/n)
若在第5層分支,係數爲d Sim(A,B)=1*1*1*1*d*cos*(n*π/180)((n-k+1)/n)
函數
當編碼相同,而只有末尾是「#」時,那麼認爲其類似度爲e。
例如Ad02B04# 非洲人 亞洲人
則其類似度爲e。測試
其中n是分支層的節點分支總數,k是兩個分支間的距離。
如:人 Aa01A01=
和 少兒 Ab04B01=
以A開頭的子分支只有14個,分別是Aa*——An*,而不是以A開頭的全部結點的個數;在第2層,人的編碼是a,少兒的編碼是b因此k=1。ui
該文獻中給出的參數值爲a=0.65
,b=0.8
,c=0.9
,d=0.96
,e=0.5
,f=0.1
。
如:人 Aa01A01=
和 少兒 Ab04B01=
因爲A開頭的分支個數爲14個,因此n=14;在第2層,人的編碼是a,少兒的編碼是b因此k=1。
package edu.shu.similarity.cilin;
import com.google.common.base.Preconditions;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.junit.Test;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import static java.lang.Math.PI;
import static java.lang.Math.cos;
/**
* <p>
* Created with IntelliJ IDEA. 2015/8/2 21:54
* </p>
* <p>
* ClassName:WordSimilarity
* </p>
* <p>
* Description:<br/>
* "=" 表明 相等 同義 <br/>
* "#" 表明 不等 同類 屬於相關詞語 <br/>
* "@" 表明 自我封閉 獨立 它在詞典中既沒有同義詞, 也沒有相關詞 <br/>
* </P>
*
* @author Wang Xu
* @version V1.0.0
* @since V1.0.0
*/
@Log4j2
//注意使用Log4j2的註解,那麼在pom中必須引入2.x版本的log4j,若是使用Log4j註解,pom中引入1.x版本的log4j
//相應的配置文件也要一致,2.x版本配置文件爲log4j2.xml,1.x版本配置文件爲log4j.xml
public class WordSimilarity {
/**
* when we use Lombok's Annotation, such as @Log4j
*
* @Log4j <br/>
* public class LogExample {
* }
* <p>
* will generate:
* public class LogExample {
* private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.Logger.getLogger(LogExample.class);
* }
* </p>
*/
//定義一些常數先
private final double a = 0.65;
private final double b = 0.8;
private final double c = 0.9;
private final double d = 0.96;
private final double e = 0.5;
private final double f = 0.1;
private final double degrees = 180;
//存放的是以詞爲key,以該詞的編碼爲values的List集合,其中一個詞可能會有多個編碼
private static Map<String, ArrayList<String>> wordsEncode = new HashMap<String, ArrayList<String>>();
//存放的是以編碼爲key,以該編碼多對應的詞爲values的List集合,其中一個編碼可能會有多個詞
private static Map<String, ArrayList<String>> encodeWords = new HashMap<String, ArrayList<String>>();
/**
* 讀取同義詞詞林並將其注入wordsEncode和encodeWords
*/
private static void readCiLin() {
InputStream input = WordSimilarity.class.getClassLoader().getResourceAsStream("cilin.txt");
List<String> contents = null;
try {
contents = IOUtils.readLines(input);
} catch (IOException e) {
e.printStackTrace();
log.error(e.getMessage());
}
for (String content : contents) {
content = Preconditions.checkNotNull(content);
String[] strsArr = content.split(" ");
String[] strs = Preconditions.checkNotNull(strsArr);
String encode = null;
int length = strs.length;
if (length > 1) {
encode = strs[0];//獲取編碼
}
ArrayList<String> encodeWords_values = new ArrayList<String>();
for (int i = 1; i < length; i++) {
encodeWords_values.add(strs[i]);
}
encodeWords.put(encode, encodeWords_values);//以編碼爲key,其後全部值爲value
for (int i = 1; i < length; i++) {
String key = strs[i];
if (wordsEncode.containsKey(strs[i])) {
ArrayList<String> values = wordsEncode.get(key);
values.add(encode);
//從新放置回去
wordsEncode.put(key, values);//以某個value爲key,其可能的全部編碼爲value
} else {
ArrayList<String> temp = new ArrayList<String>();
temp.add(encode);
wordsEncode.put(key, temp);
}
}
}
}
/**
* 對外暴露的接口,返回兩個詞的類似度的計算結果
*
* @param word1
* @param word2
* @return 類似度值
*/
public double getSimilarity(String word1, String word2) {
//若是比較詞沒有出如今同義詞詞林中,則類似度爲0
if (!wordsEncode.containsKey(word1) || !wordsEncode.containsKey(word2)) {
return 0;
}
//獲取第一個詞的編碼
ArrayList<String> encode1 = getEncode(word1);
//獲取第二個詞的編碼
ArrayList<String> encode2 = getEncode(word2);
double maxValue = 0;//最終的計算結果值,取全部類似度裏面結果最大的那個
for (String e1 : encode1) {
for (String e2 : encode2) {
log.info(e1);
log.info(e2);
String commonStr = getCommonStr(e1, e2);
int length = StringUtils.length(commonStr);
double k = getK(e1, e2);
double n = getN(commonStr);
log.info("k--" + k);
log.info("n--" + n);
log.info("length--" + length);
double res = 0;
//若是有一個以「@」那麼表示自我封閉,確定不在一棵樹上,直接返回f
if (e1.endsWith("@") || e2.endsWith("@") || 0 == length) {
if (f > maxValue) {
maxValue = f;
}
continue;
}
if (1 == length) {
//說明在第二層上計算
res = a * cos(n * PI / degrees) * ((n - k + 1) / n);
} else if (2 == length) {
//說明在第三層上計算
res = b * cos(n * PI / degrees) * ((n - k + 1) / n);
} else if (4 == length) {
//說明在第四層上計算
res = c * cos(n * PI / degrees) * ((n - k + 1) / n);
} else if (5 == length) {
//說明在第五層上計算
res = d * cos(n * PI / degrees) * ((n - k + 1) / n);
} else {
//注意不存在前面七個字符相同,而結尾不一樣的狀況,因此這個分支必定是8個字符都相同,那麼只需比較結尾便可
if (e1.endsWith("=") && e2.endsWith("=")) {
//說明兩個徹底相同
res = 1;
} else if (e1.endsWith("#") && e2.endsWith("#")) {
//只有結尾不一樣,說明結尾是「#」
res = e;
}
}
log.info("res :" + res);
if (res > maxValue) {
maxValue = res;
}
}
}
return maxValue;
}
/**
* 判斷一個詞在同義詞詞林中是不是自我封閉的,是不是獨立的
*
* @param source
* @return
*/
private boolean isIndependent(String source) {
Iterator<String> iter = wordsEncode.keySet().iterator();
while (iter.hasNext()) {
String key = iter.next();
if (StringUtils.equalsIgnoreCase(key, source)) {
ArrayList<String> values = wordsEncode.get(key);
for (String value : values) {
if (value.endsWith("@")) {
return true;
}
}
}
}
return false;
}
/**
* 根據word的內容,返回其對應的編碼
*
* @param word
* @return
*/
public ArrayList<String> getEncode(String word) {
return wordsEncode.get(word);
}
/**
* 計算N的值,N表示所在分支層分支數,如:人 Aa01A01= 和 少兒 Ab04B01=,以A開頭的子分支只有14個
* 這一點在論文中說的很是不清晰,因此以國人的文章進行編碼真是痛苦
*
* @param encodeHead 輸入兩個字符串的公共開頭
* @return 通過計算以後獲得N的值
*/
public int getN(String encodeHead) {
int length = StringUtils.length(encodeHead);
switch (length) {
case 1:
return getCount(encodeHead, 2);
case 2:
return getCount(encodeHead, 4);
case 4:
return getCount(encodeHead, 5);
case 5:
return getCount(encodeHead, 7);
default:
return 0;
}
}
public int getCount(String encodeHead, int end) {
Set<String> res = new HashSet<String>();
Iterator<String> iter = encodeWords.keySet().iterator();
while (iter.hasNext()) {
String curr = iter.next();
if (curr.startsWith(encodeHead)) {
String temp = curr.substring(0, end);
if (res.contains(temp)) {
continue;
} else {
res.add(temp);
}
}
}
return res.size();
}
/**
* @param encode1 第一個編碼
* @param encode2 第二個編碼
* @return 這兩個編碼對應的分支間的距離,用k表示
*/
public int getK(String encode1, String encode2) {
String temp1 = encode1.substring(0, 1);
String temp2 = encode2.substring(0, 1);
if (StringUtils.equalsIgnoreCase(temp1, temp2)) {
temp1 = encode1.substring(1, 2);
temp2 = encode2.substring(1, 2);
} else {
return Math.abs(temp1.charAt(0) - temp2.charAt(0));
}
if (StringUtils.equalsIgnoreCase(temp1, temp2)) {
temp1 = encode1.substring(2, 4);
temp2 = encode2.substring(2, 4);
} else {
return Math.abs(temp1.charAt(0) - temp2.charAt(0));
}
if (StringUtils.equalsIgnoreCase(temp1, temp2)) {
temp1 = encode1.substring(4, 5);
temp2 = encode2.substring(4, 5);
} else {
return Math.abs(Integer.valueOf(temp1) - Integer.valueOf(temp2));
}
if (StringUtils.equalsIgnoreCase(temp1, temp2)) {
temp1 = encode1.substring(5, 7);
temp2 = encode2.substring(5, 7);
} else {
return Math.abs(temp1.charAt(0) - temp2.charAt(0));
}
return Math.abs(Integer.valueOf(temp1) - Integer.valueOf(temp2));
}
/**
* 獲取編碼的公共部分字符串
*
* @param encode1
* @param encode2
* @return
*/
public String getCommonStr(String encode1, String encode2) {
int length = StringUtils.length(encode1);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
if (encode1.charAt(i) == encode2.charAt(i)) {
sb.append(encode1.charAt(i));
} else {
break;
}
}
int sbLen = StringUtils.length(sb);
//注意第三層和第五層均有兩個字符,因此長度不可能出現3和6的狀況
if (sbLen == 3 || sbLen == 6) {
sb.deleteCharAt(sbLen - 1);
}
return String.valueOf(sb);
}
@Test
public void testGetN() {
readCiLin();
int a = getN("A");
System.out.println(a);
}
@Test
public void testGetK() {
int k = getK("Aa01A01=", "Aa01A01=");
System.out.println(k);
}
@Test
public void testGetCommonStr() {
String commonStr = getCommonStr("Aa01A01=", "Aa01A03=");
System.out.println(commonStr);
}
@Test
public void testGetSimilarity() {
readCiLin();
double similarity = getSimilarity("人民", "國民");
System.out.println("人民--" + "國民:" + similarity);
similarity = getSimilarity("人民", "羣衆");
System.out.println("人民--" + "羣衆:" + similarity);
similarity = getSimilarity("人民", "黨羣");
System.out.println("人民--" + "黨羣:" + similarity);
similarity = getSimilarity("人民", "良民");
System.out.println("人民--" + "良民:" + similarity);
similarity = getSimilarity("人民", "同志");
System.out.println("人民--" + "同志:" + similarity);
similarity = getSimilarity("人民", "成年人");
System.out.println("人民--" + "成年人:" + similarity);
similarity = getSimilarity("人民", "市民");
System.out.println("人民--" + "市民:" + similarity);
similarity = getSimilarity("人民", "親屬");
System.out.println("人民--" + "親屬:" + similarity);
similarity = getSimilarity("人民", "志願者");
System.out.println("人民--" + "志願者:" + similarity);
similarity = getSimilarity("人民", "先鋒");
System.out.println("人民--" + "先鋒:" + similarity);
}
@Test
public void testGetSimilarity2() {
readCiLin();
double similarity = getSimilarity("非洲人", "亞洲人");
System.out.println(similarity);
double similarity1 = getSimilarity("驕傲", "仔細");
System.out.println(similarity1);
}
}
說明,詞語類似度是個數值,通常取值範圍在[0,1]之間,在原論文中,使用cos函數計算主要是將值歸一化到[0,1]之間,能夠將cos函數看做是一個調節因子。
testGetSimilarity的測試結果以下所示:
人民--國民:1.0
人民--羣衆:0.9576614882494312
人民--黨羣:0.8978076452338418
人民--良民:0.7182461161870735
人民--同志:0.6630145969121822
人民--成年人:0.6306922220793977
人民--市民:0.5405933332109123
人民--親屬:0.36039555547394153
人民--志願者:0.22524722217121346
人民--先鋒:0.18019777773697077
本文使用的是同義詞詞林的擴展版,而原論文使用的是同義詞詞林,因爲二者存在微小差距,因此本文計算結果與論文中的計算結果存在稍許偏差,若是算法沒錯,這是能夠理解的!
以上僅爲我的理解,如若發現錯誤,歡迎你們積極留言指正!
目前整個項目已經推送到GitHub上了,地址點我。注意在文章末尾所注的參考資料中的連接裏面的計算方法在求n的時候存在錯誤,代碼我不肯定是不是其本身實現,望莫要受其誤導!