Java模糊字符串匹配与名称

24

我有一个独立的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);
}

大多数时候,我会在名字和姓氏上得到匹配结果,但当无法匹配时,我会使用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->Michael),这个方法可以很好地工作,但当我在高速缓存中完全找不到任何一个传入的名称时,就会返回错误的匹配结果。为了避免这种情况发生,我在 findClosetMatch() 函数中设置了最小值为15(即差距不超过15个字符); 这在大多数情况下都有效,但我仍然有一些错误匹配发生:Mike Thompson 会匹配到 Mike Thomas 等。

除了想出一种将主键输入到要加载的文件中的方法外,还有没有其他匹配算法可以帮助解决这个问题?


8
答案可能是对数据提供者施加一些压力。用烂数据能做的事情只有那么多。在某个时候,你不能通过代码来解决问题,而且你真的不应该这样做,因为你不应该允许出现错误匹配。 - Bohemian
理想情况下,我们最终会在供应商那边解决这个问题,但现在我大部分都是手动处理。本来希望能找到简化这个问题的方法,可这可能只是一个棘手的问题。 - Durandal
旁注:您是否意识到,由于哈希码冲突,您的邮件可能不包含所有收件人? - assylias
@assylias 在这种情况下哈希碰撞不是极其不可能吗?这是一个单次运行的过程,在每次运行后都会消失。我还应该补充一点,每次运行中被缓存的最大人数约为1000人。 - Durandal
3
我不确定你遇到的是哪种拼写错误,但距离为15相当极端。在比较之前,我会对单词进行预处理(转换为小写字母,去除重音符号/转写等)。分别匹配名字和姓氏也可以减少最小检查距离。最后,在找到匹配项后最好进行一些额外的安全检查 - 比如检查他们是否有相同的出生日期或地址,... - Pavel Horal
1
@MagicMan,我同意Bohemian的观点,但如果你真的有那么糟糕的数据提供者,你可以手动预处理CSV。在那里,你可以让你的软件来协助你。例如,你可以引入预处理阶段,找到所有模糊匹配并报告它们。同时,它还应该首先报告有歧义的匹配(当Mike ThompsonMike Thomas都存在且都与CSV中的一个名称匹配时)。这样,你就可以手动修正这些错误并运行第二阶段,实际上修改数据库。 - Alexey Andreev
4个回答

46

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

事实和观察

  1. 最大迭代次数为1000。
  2. Levenshtein距离的15听起来对我来说太高了
  3. 通过经验观察数据,你可以知道你的模糊匹配应该是什么样子(对于模糊匹配有许多情况,每种情况都取决于数据存在的问题)。
  4. 通过构建类似API的东西,你可以插入许多算法,包括自己和其他人的算法Soundex,而不只是依赖于一个。

要求

我将您的问题解释为需要以下两个方面:

  1. 您有“PersonDO”对象,您希望通过基于名称的键查找这些对象。听起来像是因为您需要一个预先存在的“PersonDO”,每个唯一名称都存在一个,并且同一个名称可能会在您的循环/工作流程中出现多次。
  2. 由于输入数据不是纯净的,因此您需要“模糊匹配”。为了本算法的目的,我们将假设如果名称“匹配”,则始终应使用相同的PersonDO(换句话说,一个人的唯一标识符是他们的姓名,在现实生活中显然不是这样,但在这里似乎可以工作)。

实现

接下来,让我们看看如何改进代码:

1. 清理:不必要的哈希码操作。

您不需要自己生成哈希码。这会使问题有点混乱。

您只需为名字和姓氏的组合生成哈希码。如果将连接后的字符串作为键提供给HashMap,那么这正是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的功能。如果我们总是确切地知道键(即唯一标识符),那么我们当然会使用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);
     }
   };
}

接下来,我们创建一个评分通用名称的方法。我们应该分别对名字和姓氏进行评分,因为这样可以减少干扰。例如,我们不关心名字是否与姓氏的任何部分匹配——除非你的名字可能意外地出现在姓氏字段中,反之亦然,这需要有意识地考虑,而不是偶然发生(我们稍后会解决这个问题)。
请注意,我们不再需要“最大莱文斯坦距离”。这是因为我们将它们归一化到长度,并且稍后我们将选择最接近的匹配项。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));
}

如上所述,您可以插入第三方或其他单词匹配算法,并从所有算法的共享知识中获益。
现在,我们遍历整个列表并给每个姓名打分。注意,我添加了一个“调整”的选项。调整可能包括:
- 逆转:如果PersonDO是"Benjamin Franklin",但CSV表格可能包含"Franklin, Benjamin",则您需要对逆转的姓名进行纠正。在这种情况下,您可能需要添加一个checkForReversal方法,该方法将反向计算得分,并在得分显着更高时采用该值。如果完全相反,则应给它1.0分数。 - 缩写:如果名字的第一个/最后一个单词完全相同,而另一个单词被包含在候选人的名字中(或反之亦然),则您可能希望给分数加分。这可能表示缩写,例如"Samantha / Sam"。 - 常见昵称:您可以添加一组已知的昵称("Robert-> Bob,Rob,Bobby,Robby"),然后将搜索名称与它们中的所有名称进行匹配,并取最高分。如果与其中任何一个匹配,则可能会给它1.0分数。
如您所见,将其构建为一系列API可以方便地进行调整。
接下来是算法的步骤:
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. 以不同方式报告“模糊匹配”。

最后,您会注意到findClosestMatch不仅返回人名,还返回Match——这样我们就可以修改程序,以便将模糊匹配与精确匹配区别对待。

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

  • 报告猜测:将所有基于模糊性匹配的名称保存到列表中,以便稍后报告和审核。
  • 先验证:您可能希望添加一个控件来开关它是否实际使用模糊匹配或只是报告它们,以便在数据输入之前对数据进行调整。
  • 数据保护:如果匹配是模糊的,您可能希望将任何对模糊匹配进行的编辑标记为“不确定”。例如,如果匹配是模糊的,则可以禁止对个人记录进行任何“重大编辑”。

结论

如您所见,自己编写此代码不太费力。毫无疑问,不会有库能够像您了解数据一样预测名称。

按上面示例所示逐步构建它将允许您轻松迭代和微调,甚至插入第三方库以提高评分,而不是完全依赖它们——包括它们的缺陷。


我从来没有回去删除散列函数;最初我计划添加散列的几个其他属性,但我发现它们在两个来源之间并不一致,所以最终又回到了名字+姓氏。好答案! - Durandal

2
以下是我对类似用例的处理方法:
  • 分别匹配名字和姓氏,这样可以进行更精确的匹配并消除一些误报:
distance("a b", "a c")                        等于   33%
max(distance("a", "a"), distance("b", "c"))   等于   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
...

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

2
没有最好的解决方案,无论如何你都必须处理某种启发式算法。但是你可以寻找另一个Levenshtein距离实现(或自己实现)。这个实现必须为不同字符的不同字符操作(插入、删除)给出不同的分数。例如,你可以为键盘上相邻的字符对给出较低的分数。此外,你可以根据字符串长度动态计算最大距离阈值。
我还有一个性能提示。每次计算Levenshtein距离时,执行n * m次操作,其中n和m是字符串的长度。有一种Levenshtein automaton,你可以建立它一次,然后对每个字符串进行快速评估。但要小心,因为NFA非常昂贵,你需要先将其转换为DFA。
也许你应该看看Lucene。我希望它包含了你需要的所有模糊搜索功能。或者你甚至可以使用你的DBMS全文搜索,如果它支持。例如,PostgreSQL支持全文搜索。

2
  1. 使用数据库进行搜索?使用正则表达式在选择语句中,或使用LIKE运算符。

  2. 分析您的数据库并尝试构建哈夫曼树或多个表以执行键值搜索。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接