Java模糊字符串与名称匹配

时间:2014-01-11 02:07:14

标签: java string levenshtein-distance

我有一个独立的CSV数据加载过程,我用Java编码,必须使用一些模糊字符串匹配。这绝对不是理想的,但我没有太多选择。我使用名字和姓氏进行匹配,并在运行开始时缓存所有可能性。找到匹配后,我需要该人在运行期间对多个位置。我使用guava的Objects.hashCode()来创建名字和姓氏的散列。

缓存机制如下所示:

Map<Integer,PersonDO> personCache = Maps.newHashMap();
for(PersonDO p: dao.getPeople()) {
    personCache.put(Objects.hashCode(p.getFirstName(),p.getLastName()), p);
}

大多数时候我会在firstname + lastname上点击,但是当它错过时我会使用Apache的StringUtils.getLevenshteinDistance()来尝试匹配它。这就是匹配逻辑流程的方式:

    person = personCache.get(Objects.hashCode(firstNameFromCSV,lastNameFromCSV));
    if(person == null) {//fallback to fuzzy matching
        person = findClosetMatch(firstNameFromCSV+lastNameFromCSV);

    }

这是findClosetMatch()方法:

private PersonDO findClosetMatch(String name) {
    int min = 15;//initial value
    int testVal=0;
    PersonDO matchedPerson = null;
    for(PersonDO person: personCache.values()) {
        testVal = StringUtils.getLevenshteinDistance(name,person.getFirstName()+person.getLastName());
        if( testVal < min ) {
            min = testVal;
            matchedPerson = person;
        }
    }
    if(matchedPerson == null) {
        throw new Exception("Unable to find person: " + name) 
    }
    return matchedPerson;
}

这适用于简单的拼写错误,拼写错误和缩写名称(即Mike-&gt; Michael),但是当我完全错过缓存中的一个传入名称时,我最终会返回误报。为了防止这种情况发生,我将findClosetMatch()中的最小值设置为15(即不超过15个字符);它大部分时间都有效,但我仍然发生了一些不匹配:Mike Thompson点击Mike Thomas等。

除了找出将主键放入正在加载的文件的方法之外,有没有人看到改进此过程的方法?任何其他可以帮助的匹配算法?

4 个答案:

答案 0 :(得分:40)

当我看到这个问题时,我注意到一些关键事实可以作为一些改进:

事实和意见

  1. 最大迭代次数为1000。
  2. 15对于Levenshtein距离听起来真的对我很高。
  3. 你知道,通过经验观察数据,你的模糊匹配应该是什么样的(模糊匹配有很多种情况,每种情况都取决于为什么数据不好)。
  4. 通过构建此类API,您可以插入许多算法,包括您自己的算法和其他类似的算法,例如Soundex,而不仅仅依赖于一个算法。
  5. 要求

    我已将您的问题解释为需要以下两项内容:

    1. 您希望通过基于名称的密钥查找PersonDO个对象。听起来你想这样做是因为你需要一个预先存在的PersonDO,其中每个唯一名称存在一个,并且你的循环/工作流程中可能会出现同一个名字
    2. 你需要&#34;模糊匹配&#34;因为传入的数据不纯净。出于此算法的目的,我们假设如果名称&#34;匹配&#34;,则应始终使用相同的PersonDO (换句话说,一个人& #39;唯一的标识符是他们的名字,这在现实生活中显然不是这样,但在这里似乎对你有用。)
    3. 实施

      接下来,让我们看一下代码的一些改进:

      <强> 1。清理:不必要的哈希码操作。

      您不需要自己生成哈希码。这有点混淆了这个问题。

      您只是为firstname + lastname的组合生成哈希码。如果你将连接字符串作为键给它,那么这正是HashMap的作用。所以,就这样做(并添加一个空格,以防万一我们想要稍后从密钥中解析出第一个/最后一个)。

      Map<String, PersonDO> personCache = Maps.newHashMap();
      
      public String getPersonKey(String first, String last) {
        return first + " " + last;
      }
      
      ...
      // Initialization code
      for(PersonDO p: dao.getPeople()) {
          personCache.put(getPersonKey(p.getFirstName(), p.getLastName()), p);
      }
      

      <强> 2。清理:构建检索功能以执行查找。

      由于我们已经更改了地图中的键,因此我们需要更改查找功能。我们将其构建为迷你API。如果我们总是完全知道密钥(即唯一ID),我们当然只会使用Map.get。所以我们从那开始,但是因为我们知道我们需要添加模糊匹配,所以我们会在可能发生这种情况的情况下添加一个包装器:

      public PersonDO findPersonDO(String searchFirst, String searchLast) {
        return personCache.get(getPersonKey(searchFirst, searchLast));
      }
      

      第3。使用评分自己构建模糊匹配算法。

      请注意,由于您使用的是Guava,我在这里使用了一些便利(OrderingImmutableListDoubles等。)

      首先,我们希望保留我们所做的工作,以确定匹配的接近程度。使用POJO执行此操作:

      class Match {
         private PersonDO candidate;
         private double score; // 0 - definitely not, 1.0 - perfect match
      
         // Add candidate/score constructor here
         // Add getters for candidate/score here
      
         public static final Ordering<Match> SCORE_ORDER =
             new Ordering<Match>() {
           @Override
           public int compare(Match left, Match right) {
             return Doubles.compare(left.score, right.score);
           }
         };
      }
      

      接下来,我们创建一个评估通用名称的方法。我们应该分别对名和姓进行评分,因为它可以降低噪音。例如,我们不关心名字是否与姓氏的任何部分匹配 - ,除非您的名字可能意外地在姓氏字段中,反之亦然,您应该有意识地而不是偶然地考虑(我们&# 39;稍后会解决这个问题)

      请注意,我们不再需要&#34; max levenshtein距离&#34;。这是因为我们将它们标准化为长度,我们将在稍后选择最接近的匹配。 15个字符添加/编辑/删除似乎非常高,并且因为我们通过评分最小化了空白的名字/姓氏问题如果你想要的话,我们现在可以选择最多3-4个(将其他任何东西评分为0)。

      // Typos on first letter are much more rare.  Max score 0.3
      public static final double MAX_SCORE_FOR_NO_FIRST_LETTER_MATCH = 0.3;
      
      public double scoreName(String searchName, String candidateName) {
        if (searchName.equals(candidateName)) return 1.0
      
        int editDistance = StringUtils.getLevenshteinDistance(
            searchName, candidateName);
      
        // Normalize for length:
        double score =
            (candidateName.length() - editDistance) / candidateName.length();
      
        // Artificially reduce the score if the first letters don't match
        if (searchName.charAt(0) != candidateName.charAt(0)) {
          score = Math.min(score, MAX_SCORE_FOR_NO_FIRST_LETTER_MATCH);
        }
      
        // Try Soundex or other matching here.  Remember that you don't want
        // to go above 1.0, so you may want to create a second score and
        // return the higher.
      
        return Math.max(0.0, Math.min(score, 1.0));
      }
      

      如上所述,您可以插入第三方或其他字匹配算法,并从所有这些算法的共享知识中获益。

      现在,我们浏览整个列表并对每个名称进行评分。请注意,我已经为&#34; tweaks&#34;添加了一个位置。调整可能包括:

      • 撤消:如果PersonDO是&#34; Benjamin Franklin&#34;,但CSV表可能包含&#34; Franklin,Benjamin&#34;,那么你会想要更正颠倒的名字。在这种情况下,您可能希望添加一个方法checkForReversal,该方法将反向对该名称进行评分,并在该值高得多时获取该分数。 如果完全匹配,则会给出1.0分
      • 缩略语:如果候选人姓名匹配且另一个名称​​完全包含,则您可能希望给分数带来额外奖励(反之亦然) )。这可能表示缩写。你可能想给Levenshtein津贴1来说明&#34;尼克/尼古拉斯&#34;或类似的。
      • 常见昵称:您可以添加一组已知的昵称(&#34; Robert - &gt; Bob,Rob,Bobby,Robby&#34;)然后根据所有昵称对搜索名称进行评分他们并取得最高分。 如果匹配其中任何一个,您可能会给它1.0分

      正如您所看到的,将其构建为一系列API,为我们提供了合理的位置,可以轻松地将其调整为我们心中的内容。

      用alogrithm:

      public static final double MIN_SCORE = 0.3;
      
      public List<Match> findMatches(String searchFirst, String searchLast) {
        List<Match> results = new ArrayList<Match>();
      
        // Keep in mind that this doesn't scale well.
        // With only 1000 names that's not even a concern a little bit, but
        // thinking ahead, here are two ideas if you need to:
        // - Keep a map of firstnames.  Each entry should be a map of last names.
        //   Then, only iterate through last names if the firstname score is high
        //   enough.
        // - Score each unique first or last name only once and cache the score.
        for(PersonDO person: personCache.values()) {
          // Some of my own ideas follow, you can tweak based on your
          // knowledge of the data)
      
          // No reason to deal with the combined name, that just makes things
          // more fuzzy (like your problem of too-high scores when one name
          // is completely missing).
          // So, score each name individually.
      
          double scoreFirst = scoreName(searchFirst, person.getFirstName());
          double scoreLast = scoreName(searchLast, person.getLastName());
      
          double score = (scoreFirst + scoreLast)/2.0;
      
          // Add tweaks or alternate scores here.  If you do alternates, in most
          // cases you'll probably want to take the highest, but you may want to
          // average them if it makes more sense.
      
          if (score > MIN_SCORE) {
            results.add(new Match(person, score));
          }
        }
      
        return ImmutableList.copyOf(results);
      }
      

      现在我们修改你的findClosestMatch以获得所有匹配中最高的一个(如果列表中没有,则抛出NoSuchElementException。)

      可能的调整:

      • 您可能想要检查多个名字是否得分非常接近,并且要么报告亚军(见下文),要么稍后跳过该行进行手动选择。
      • 您可能想要报告其他匹配项数(如果您的评分算法非常紧张)。

      代码:

      public Match findClosestMatch(String searchFirst, String searchLast) {
        List<Match> matches = findMatch(searchFirst, searchLast);
      
        // Tweak here
      
        return Match.SCORE_ORDER.max(list);
      }
      

      ..然后修改我们原来的getter:

      public PersonDO findPersonDO(String searchFirst, String searchLast) {
        PersonDO person = personCache.get(getPersonKey(searchFirst, searchLast));
        if (person == null) {
          Match match = findClosestMatch(searchFirst, searchLast);
          // Do something here, based on score.
          person = match.getCandidate();
        }
        return person;
      }
      

      <强> 4。报告&#34;模糊&#34;不同。

      最后,您会注意到findClosestMatch并不仅仅返回一个人,它会返回Match - 这样我们就可以修改程序来区别对待模糊匹配完全匹配。

      您可能想要做的一些事情:

      • 报告猜测:将基于模糊性匹配的所有名称保存到列表中,以便您可以报告这些名称,以后可以对其进行审核。
      • 首先验证:您可能需要添加一个控件来打开和关闭它是否实际使用模糊匹配或仅报告它们,以便您可以在数据进入之前按摩数据。
      • 数据缺失:您可能希望将模糊匹配所做的任何编辑限定为&#34;不确定&#34;。例如,您可以禁止任何&#34;主要编辑&#34;如果匹配模糊,则为人员记录。

      结论

      正如您所看到的那样,自己不需要太多代码。令人怀疑的是,有一个图书馆可以预测名称,也可以自己了解数据。

      正如我在上面的示例中所做的那样,将允许您轻松地进行迭代和调整,甚至插入第三方库以改善您的得分而不是依赖于它们完全 - 缺点和所有。

答案 1 :(得分:2)

  1. 使用db来执行搜索?在您的选择中使用正则表达式,或使用LIKE运算符

  2. 分析您的数据库并尝试构建或使用Huffman-tree或多个表来执行键值搜索。

答案 2 :(得分:2)

没有最好的解决方案,无论如何你必须处理某种启发式方法。但是你可以寻找另一个Levenshtein距离实现(或者自己实现)。此实现必须为不同字符的不同字符操作(插入,删除)提供不同的分数。例如,您可以为键盘上的字符对提供较低的分数。此外,您可以根据字符串长度动态计算最大距离阈值。

我有一个性能提示给你。每次计算Levenshtein距离时,都会执行n * m次运算,其中 n m 是字符串的长度。您构建一次Levenshtein automaton,然后对每个字符串进行非常快速的评估。请注意,因为评估NFA非常昂贵,所以首先需要将其转换为DFA。

你应该看看Lucene。我希望它包含您需要的所有模糊搜索功能。如果支持,你甚至可以使用你的DBMS全文搜索。例如,PostgreSQL支持全文。

答案 3 :(得分:2)

这就是我对类似用例所做的事情:

  • 分别匹配名字和姓氏,这将进行更精确的匹配并消除一些误报:
distance("a b", "a c")                        is   33%
max(distance("a", "a"), distance("b", "c"))   is   100%
  • 根据输入字符串的长度确定min距离标准,即0适用于短于2个符号的字符串,1适用于短于3个符号的字符串。
int length = Math.min(s1.length(), s2.length);

int min;

if(length <= 2) min = 0; else
if(length <= 4) min = 1; else
if(length <= 6) min = 2; else
...

这两个应该适用于您的输入。