zoukankan      html  css  js  c++  java
  • 一个有意思的需求——中文匹配度

    引言

     

      最近LZ带头在做一个互联网项目,互联网的东西总是那么新鲜,这也难怪大部分猿友都喜欢互联网。这个互联网项目不仅让LZ开发了一个HBase大数据应用,近期的一次需求讨论会上,又出来一个小需求,蛮有意思的。这些需求在之前枯燥的企业内部应用开发中,还是很难见到的,毕竟内部应用更多的是业务流程的体现。

      具体的需求这里不方便透露,但简单的描述一下需求,就是如何判断两个公司名是一个。这其实就是Java当中字符串的相等判断,最简单的当然是用equals来判断。但是由于实际情况是,公司名是由客户手动输出的,难免有小小的偏差,因此equals自然就不适用了。比如“博客园科技发展有限公司”和“博客园科技发展(北京)有限公司”,这两个显然应该是一个公司,但是如果用equals,自然结果会是false。

      

    方案提出

     

      会议上,LZ简单提出了一个解决方案,就是利用分词来计算两者的匹配度,如果匹配度达到一定数值,我们就认为两个字符串是相等的。不过不论算法如何高明,准确率都不可能达到100%,这点也是LZ一再给业务同事强调的,否则到时候匹配错了,他们找LZ的茬可咋办。不过好在业务同事只是希望有一个提示功能,并不做严格的判断标准,所以系统需要做的只是初步的判断,因此准确率要求并不是特别的高,只能说越高越好。

      由于这个项目是LZ以PM介入开发的,而且LZ一直都兼任SM,所以技术方案自然是LZ说了算了。没有讨论,没有异议,方案就这么在会议上定了。

     

    小研究

     

      由于LZ最近负责的事情比较多,所以上班的时候自然是没有时间研究这些东西,只能趁着周末小小研究一下。其实LZ对于分词并不是特别了解,这些东西与算法的关联度比较高,而LZ的算法基础只能说“呵呵”。不过不管怎样,作为一个项目的领头人,总得向前冲。如果到时候算法的时间、空间成本太高,或者准确率太低,等着后续寻找大神优化也不晚。

      这件事的思路比较清晰,LZ需要做的就是两件事,第一件事就是“分词”,第二件事就是“匹配”。

     

    分词

     

      分词是比较简单的一步,有很多现成的类库可以使用,只要选择一个使用就可以了。Lucene是LZ第一个想到的,毕竟LZ也算是Apache的脑残粉,因此话不多说,第一时间就下载Lucene,开始了探究之旅。

      以下是LZ在网络上找到的示例,稍微进行了一点修改。

    package com.creditease.borrow.lucene;
    
    import java.io.IOException;
    
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
    import org.apache.lucene.util.Version;
    
    public class App 
    {
        public static void main( String[] args ) throws IOException
        {
            String text = "博客园科技发展(北京)有限公司";
            SmartChineseAnalyzer smartChineseAnalyzer = new SmartChineseAnalyzer(Version.LUCENE_47);
            TokenStream tokenStream = smartChineseAnalyzer.tokenStream("field", text);
            CharTermAttribute charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
            tokenStream.reset();
            while (tokenStream.incrementToken()) {
                System.out.print(charTermAttribute.toString() + "  ");
            }
            tokenStream.end();
            tokenStream.close();
            smartChineseAnalyzer.close();
        }
        
    }

      输出结果为以下内容。

    博  客  园  科技  发展  北京  有限公司  

      这种分词结果勉强可以接受了,最理想的应该是将“博客”放在一起,不过看来Lucene的字典里没有这个词。没关系,这对我们的计划并不影响,我们接下来开始着手匹配的事。

     

    匹配

     

      有了分词,我们要做的,就是匹配两个字符串数组,并得到一个匹配度。既然是匹配度,那么肯定就有分母和分子。我们可以挑其中一个数组的长度为分母,以另外一个数组中的元素找到匹配分词的个数作为分子。为了减少程序的复杂度,我们采用Set集合的去重特点来进行计算。就像以下程序这样。

    package com.creditease.borrow.lucene;
    
    import java.io.IOException;
    import java.util.HashSet;
    import java.util.Set;
    
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
    import org.apache.lucene.util.Version;
    
    public class App 
    {
        
        private static SmartChineseAnalyzer smartChineseAnalyzer = new SmartChineseAnalyzer(Version.LUCENE_47);
        
        public static void main( String[] args ) throws IOException
        {
            String text1 = "博客园科技发展(北京)有限公司";
            String text2 = "博客园科技发展有限公司";
            System.out.println(oneWayMatch(text1, text2));
        }
        
        public static double oneWayMatch(String text1,String text2) {
            try {
                Set<String> set = new HashSet<String>(10);
                TokenStream tokenStream = smartChineseAnalyzer.tokenStream("field", text1);
                CharTermAttribute charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                while (tokenStream.incrementToken()) {
                    set.add(charTermAttribute.toString());
                }
                int denominator = set.size();
                tokenStream.end();
                tokenStream.close();
                tokenStream = smartChineseAnalyzer.tokenStream("field", text2);
                charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                while (tokenStream.incrementToken()) {
                    set.add(charTermAttribute.toString());
                }
                int numerator = set.size() - denominator;
                double unmatchRate = ((double)numerator)/denominator;
                tokenStream.end();
                tokenStream.close();
                return unmatchRate;
            } catch (IOException e) {
                return 1D;
            }
            
        }
        
    }

      输出的结果为0,也就是说匹配度为100%。这显然是不对的,两个字符串很明显不是一模一样,得到匹配度为100%说明我们的算法还是有问题。

      仔细分析一下,问题就出在我们是拿text2中不匹配的分词作为分子,而text2中的分词在text1中全部都包含,因此分子最终会是0,那么不匹配度自然就是0(unmatchRate)。为了弥补这一缺陷,LZ想来想去最终还是决定采取“双向”(twoWay)的办法解决这个问题,可以看到LZ给上面那个方法取的名字为“单向”匹配(oneWay)。

      双向匹配其实就是将两者顺序颠倒,再进行一次匹配而已,因此我们的程序可以简单的更改为以下形式。

    package com.creditease.borrow.lucene;
    
    import java.io.IOException;
    import java.util.HashSet;
    import java.util.Set;
    
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
    import org.apache.lucene.util.Version;
    
    public class App 
    {
        
        private static SmartChineseAnalyzer smartChineseAnalyzer = new SmartChineseAnalyzer(Version.LUCENE_47);
        
        public static void main( String[] args ) throws IOException
        {
            String text1 = "博客园科技发展(北京)有限公司";
            String text2 = "博客园科技发展有限公司";
            System.out.println(twoWayMatch(text1, text2));
        }
        
        public static double twoWayMatch(String text1,String text2) {
            return (oneWayMatch(text1, text2) + oneWayMatch(text2, text1));
        }
        
        public static double oneWayMatch(String text1,String text2) {
            try {
                Set<String> set = new HashSet<String>(10);
                TokenStream tokenStream = smartChineseAnalyzer.tokenStream("field", text1);
                CharTermAttribute charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                while (tokenStream.incrementToken()) {
                    set.add(charTermAttribute.toString());
                }
                int denominator = set.size();
                tokenStream.end();
                tokenStream.close();
                tokenStream = smartChineseAnalyzer.tokenStream("field", text2);
                charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                while (tokenStream.incrementToken()) {
                    set.add(charTermAttribute.toString());
                }
                int numerator = set.size() - denominator;
                double unmatchRate = ((double)numerator)/denominator;
                tokenStream.end();
                tokenStream.close();
                return unmatchRate;
            } catch (IOException e) {
                return 1D;
            }
            
        }
        
    }

      该程序的输出结果为0.1666666666...,也就是0.17,也就是说两者的匹配度为83%。这个值显然更加接近实际的情况。可以看到,双向匹配以单向匹配为基础,将顺序颠倒的结果相加,就能得到不匹配度。

      事情原本到此就可以结束了,但是还有一个更大的难点没有处理。那就是匹配度到底设置为多少合适。这个问题到目前为止,LZ还没有更好的办法。按照上面的小例子来讲,自然是设为80%就可以。

      但是看下下面这个例子,就会发现这个数值很不正确,或者说匹配算法还是有问题。

    public static void main( String[] args ) throws IOException
        {
            String text1 = "博客园科技发展(北京)有限公司";
            String text2 = "博客园有限公司";
            System.out.println(twoWayMatch(text1, text2));
        }

      运行的结果为0.75,也就是说匹配度大约只有25%。但是很明显,上面两个公司实际上匹配度是很高的,因为最重要的三个字是匹配的。

      再仔细分析一下,问题就变成权重了。也就是说,每个词的权重应该是不一样的,这样的话,匹配的准确度可能会更高一点。我们稍微改善一下程序。(以下统一使用后面加双斜线的方式表示程序主要改变的地方)

    package com.creditease.borrow.lucene;
    
    import java.io.IOException;
    import java.util.Arrays;
    import java.util.HashSet;
    import java.util.List;
    import java.util.Set;
    
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
    import org.apache.lucene.util.Version;
    
    public class App 
    {
        
        private static SmartChineseAnalyzer smartChineseAnalyzer = new SmartChineseAnalyzer(Version.LUCENE_47);
        
        private static List<String> smallWeightWords = Arrays.asList("公司","有限公司","科技","发展","股份");
        
        private static double smallWeight = 0.3D;
        
        public static void main( String[] args ) throws IOException
        {
            String text1 = "博客园科技发展(北京)有限公司";
            String text2 = "博客园有限公司";
            System.out.println(twoWayMatch(text1, text2));
        }
        
        public static double twoWayMatch(String text1,String text2) {
            return (oneWayMatch(text1, text2) + oneWayMatch(text2, text1));
        }
        
        public static double oneWayMatch(String text1,String text2) {
            try {
                Set<String> set = new HashSet<String>(10);
                TokenStream tokenStream = smartChineseAnalyzer.tokenStream("field", text1);
                CharTermAttribute charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                while (tokenStream.incrementToken()) {
                    set.add(charTermAttribute.toString());
                }
                int denominator = set.size();
                tokenStream.end();
                tokenStream.close();
                tokenStream = smartChineseAnalyzer.tokenStream("field", text2);
                charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                int smallWeightWordsCount = 0;//
                while (tokenStream.incrementToken()) {
                    String word = charTermAttribute.toString();//
                    int tempSize = set.size();//
                    set.add(word);//
                    if (tempSize + 1 == set.size() && smallWeightWords.contains(word)) {//
                        smallWeightWordsCount++;//
                    }//
                }
                int numerator = set.size() - denominator;
                double unmatchRate = (smallWeightWordsCount * smallWeight + numerator - ((double)smallWeightWordsCount))/denominator;//
                tokenStream.end();
                tokenStream.close();
                return unmatchRate;
            } catch (IOException e) {
                return 1D;
            }
            
        }
        
    }

      现在程序的输出结果为0.4,匹配度为60%。从结果来看,依然有点不尽人意。仔细分析一下程序,会发现,我们计算不匹配度的时候,是交叉计算的。也就是说,我们使用一个数组中不匹配的数目去除以另外一个数组的大小,这可能会造成“极端”数值。

      我们需要调整程序,让数组自己与自己计算,这样就不会出现那种情况。如下。

    package com.creditease.borrow.lucene;
    
    import java.io.IOException;
    import java.util.Arrays;
    import java.util.HashSet;
    import java.util.List;
    import java.util.Set;
    
    import org.apache.lucene.analysis.TokenStream;
    import org.apache.lucene.analysis.cn.smart.SmartChineseAnalyzer;
    import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
    import org.apache.lucene.util.Version;
    
    public class App 
    {
        
        private static SmartChineseAnalyzer smartChineseAnalyzer = new SmartChineseAnalyzer(Version.LUCENE_47);
        
        private static List<String> smallWeightWords = Arrays.asList("公司","有限公司","科技","发展","股份");
        
        private static double smallWeight = 0.3D;
        
        public static void main( String[] args ) throws IOException
        {
            String text1 = "博客园科技发展(北京)有限公司";
            String text2 = "博客园有限公司";
            System.out.println(twoWayMatch(text1, text2));
        }
        
        public static double twoWayMatch(String text1,String text2) {
            return (oneWayMatch(text1, text2) + oneWayMatch(text2, text1));
        }
        
        public static double oneWayMatch(String text1,String text2) {
            try {
                Set<String> set = new HashSet<String>(10);
                TokenStream tokenStream = smartChineseAnalyzer.tokenStream("field", text1);
                CharTermAttribute charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                while (tokenStream.incrementToken()) {
                    set.add(charTermAttribute.toString());
                }
                int originalCount = set.size();//
                tokenStream.end();
                tokenStream.close();
                tokenStream = smartChineseAnalyzer.tokenStream("field", text2);
                charTermAttribute = tokenStream.getAttribute(CharTermAttribute.class);
                tokenStream.reset();
                int smallWeightWordsCount = 0;
                int denominator = 0;//
                while (tokenStream.incrementToken()) {
                    denominator++;//
                    String word = charTermAttribute.toString();
                    int tempSize = set.size();
                    set.add(word);
                    if (tempSize + 1 == set.size() && smallWeightWords.contains(word)) {
                        smallWeightWordsCount++;
                    }
                }
                int numerator = set.size() - originalCount;
                double unmatchRate = (smallWeightWordsCount * smallWeight + numerator - ((double)smallWeightWordsCount))/denominator;//
                tokenStream.end();
                tokenStream.close();
                return unmatchRate;
            } catch (IOException e) {
                return 1D;
            }
            
        }
        
    }

      程序的输出结果为0.2285714285714286,也就是匹配度大约为77%,这个数值还是比较科学的。这次我们主要调整了分母,将分母调整为不匹配元素自己的数组大小。

      现在我们需要做的就很简单了,就是把有可能改变的地方都在程序当中做成可配置的,比如从数据库读取。需要做成可配置项的内容有以下几个。

      1、低权重的词语,也就是smallWeightWords。

      2、低权重的数值,也就是smallWeight。

      3、匹配度的最小值,也就是说匹配度大于等于多少的时候,我们就认为是一个公司。

      具体如何做成可配置项,这里LZ就不再赘述了,真实的web项目当中有无数种办法可以达到这个目的,最常用的当然是存储到数据库。但第一项更适合放入数据库,后面两项更适合存放在配置文件当中。无论放在哪里,这些配置都要支持动态刷新,这样应用在运行的时候就可以动态调整判断规则了。

      

    小结

     

      LZ的算法不一定是最好的,或者说一定不是最好的。但是有时候慢慢解决一个问题,让答案逐渐靠近自己的判断也是一种乐趣不是吗?

  • 相关阅读:
    洛谷 P1650 田忌赛马(dp)
    卡常技巧(持续更新)
    CF106C Buns(多重背包 单调队列优化)
    洛谷 P1385 密令(dp,离线)
    CF245H Queries for Number of Palindromes(dp,二维前缀和)
    CF1061C Multiplicity(dp)
    对拍技巧
    牛客练习赛89 解题报告(ABCDF)
    洛谷 P1018 [NOIP2000 提高组] 乘积最大(dp,高精度)
    洛谷 P5629 【AFOI-19】区间与除法(ST表)
  • 原文地址:https://www.cnblogs.com/zuoxiaolong/p/lang2.html
Copyright © 2011-2022 走看看