web-dev-qa-db-ja.com

Java名前と一致するファジー文字列

JavaでコーディングしたスタンドアロンのCSVデータの読み込みプロセスがあります。これは、あいまいな文字列マッチングを使用する必要があります。これは明らかに理想的ではありませんが、あまり選択肢がありません。姓と名を使用して一致しています。実行の開始時にすべての可能性をキャッシュします。一致を見つけた後、実行中にそのpersonオブジェクトの複数の場所が必要です。グアバの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-> Michael)で正常に機能しますが、キャッシュ内の入力名の1つが完全に見つからないと、最終的に誤検出の一致が返されます。これを防ぐには、findClosetMatch()のmin値を15に設定します(つまり、15文字以下にします)。それはほとんどの時間で機能しますが、いくつかの不一致が発生しました:_Mike Thompson_で_Mike Thomas_ヒ​​ットなど.

ロードされているファイルに主キーを取得する方法を理解する以外に、このプロセスを改善する方法を誰かが見ていますか?ここで役立つ他のマッチングアルゴリズムはありますか?

20
Durandal

この問題を見ると、いくつかの改善の基礎となるいくつかの重要な事実に気づきました。

事実と観察

  1. 最大反復回数は1000です。
  2. レーベンシュタイン距離音の場合は15本当に高い高い。
  3. 経験的にデータを観察することで、ファジーマッチングがどのように見えるかがわかります(ファジーマッチングには多くのケースがあり、それぞれがなぜデータが悪いかに依存します)。
  4. このようなAPIを構築することで、1つだけに依存するのではなく、独自のアルゴリズムや Soundex などの他のアルゴリズムを組み込むことができます。

必要条件

私はあなたの問題を次の2つが必要であると解釈しました:

  1. 名前に基づくキーを使用して検索するPersonDOオブジェクトがあります。 一意の名前ごとに1つ存在する既存のPersonDOが必要であり、これも同じであるため、これを実行したいようですループ/ワークフローで名前が複数回現れることがあります。
  2. 入力データは純粋ではないため、「ファジーマッチング」が必要です。このアルゴリズムでは、名前が「一致する」場合、常に同じPersonDO(言い換えれば、人の一意の識別子は名前です。これは明らかに現実の世界ではそうではありませんが、ここではうまくいくようです。

実装

次に、コードを改善する方法を見てみましょう。

1。クリーンアップ:不要なハッシュコード操作

ハッシュコードを自分で生成する必要はありません。これは少し問題を混乱させます。

あなたは単に名と姓の組み合わせのハッシュコードを生成しています。これは、連結文字列をキーとして指定した場合の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);
     }
   };
}

次に、総称名をスコアリングするためのメソッドを作成します。姓と名は別々にスコアリングする必要があります。ノイズが減るからです。たとえば、名が姓の一部と一致しているかどうかは関係ありません—姓が誤って姓フィールドにある場合、またはその逆の場合を除き、意図的に説明する必要はありません。誤って(後で対処します)

「最大レーベンシュタイン距離」が不要になったことに注意してください。これは、それらを長さに正規化し、後で最も近い一致を選択するためです。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));
}

上記のように、サードパーティまたは他のワードマッチングアルゴリズムをプラグインして、それらすべての共有知識から得ることができます。

次に、リスト全体を調べて、すべての名前にスコアを付けます。 「微調整」のスポットを追加したことに注意してください。微調整には次のものがあります。

  • Reversal:PersonDOが「Benjamin Franklin」であるが、CSVシートに「Franklin、Benjamin」が含まれている可能性がある場合は、逆の名前を修正する必要があります。この場合、名前を逆にスコアリングし、スコアが大幅に高い場合はそのスコアを取るメソッドcheckForReversalを追加することをお勧めします。 正確に逆に一致した場合、スコアは1.0になります
  • 略語:姓名のいずれかが完全に一致し、もう一方が完全に一致する場合、スコアにボーナスバンプを与えることができます候補に含まれている(またはその逆)。これは、「Samantha/Sam」のような略語を示している可能性があります。
  • 一般的なニックネーム:一連の既知のニックネーム( "Robert-> Bob、Rob、Bobby、Robby")を追加して、検索名をそれらのすべてと最高のスコアを取る。 これらのいずれかに一致する場合、おそらく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);
}

..次に、元のゲッターを変更します。

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を返すことがわかります。これは、プログラムを変更して、あいまい一致を完全一致とは異なる方法で処理できるようにするためです。

あなたがおそらくこれでしたいいくつかのこと:

  • レポートの推測:あいまいさに基づいて一致したすべての名前をリストに保存して、それらを報告し、後で監査できるようにします。
  • 最初に検証する:コントロールを追加して、実際にファジー一致を使用するか、単にレポートするだけでよいのかをオン/オフにして、入ってくる前のデータ。
  • データの代表性:あいまい一致で行われた編集を「不確実」として修飾したい場合があります。たとえば、一致があいまいな場合、個人レコードへの「主要な編集」を禁止できます。

結論

ご覧のとおり、これを自分で行うにはコードが多すぎません。名前を予測し、データを自分で知ることができるライブラリが存在することは疑わしいです。

上記の例で行ったようにこれを分割してビルドすると、簡単に反復して微調整でき、サードパーティのライブラリをプラグインして、それらに完全に依存する代わりにスコアリング-障害とすべて。

40
Nicole

最善の解決策はありません。とにかく、ある種のヒューリスティックに対処する必要があります。しかし、別のレーベンシュタイン距離の実装を探す(または自分で実装する)ことができます。この実装は、異なる文字の異なる文字操作(挿入、削除)に異なるスコアを与える必要があります。たとえば、キーボード上で近接している文字のペアのスコアを低くすることができます。また、文字列の長さに基づいて最大距離のしきい値を動的に計算することもできます。

そして、私はあなたのためにパフォーマンスのヒントを持っています。レーベンシュタイン距離を計算するたびに、n * m演算が実行されます。ここで、nおよびmは文字列の長さです。 Levenshtein automaton があり、一度構築すると、各文字列に対して非常に高速に評価されます。 NFAの評価には非常に費用がかかるため、最初にDFAに変換する必要があります。

Lucene をご覧ください。必要なファジー検索機能がすべて含まれているといいのですが。サポートされている場合は、DBMSフルテキスト検索を使用することもできます。たとえば、PostgreSQLはフルテキストをサポートしています。

2
Alexey Andreev
  1. Dbを使用して検索を実行しますか?選択で正規表現を使用するか、LIKE演算子を使用する

  2. データベースを分析し、ハフマンツリーまたは複数のテーブルを作成して、Key-Value検索を実行します。

2

これは私が同様のユースケースでやったことです:

  • 名と姓を別々に照合します。これにより、より正確な照合が行われ、誤検出の一部が排除されます。
 distance( "ab"、 "ac")は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つは入力に対して機能するはずです。

2
Andrey Chaschev