web-dev-qa-db-fra.com

Java Chaîne floue correspondant aux noms

J'ai un processus de chargement de données CSV autonome que j'ai codé en Java qui doit utiliser une correspondance de chaîne floue. Ce n'est certainement pas idéal, mais je n'ai pas beaucoup de choix. Je je suis en utilisant un prénom et un nom et je cache toutes les possibilités au début d'une course. Après avoir trouvé la correspondance, j'ai besoin de cet objet personne à plusieurs endroits pendant la course. J'ai utilisé la fonction Objects.hashCode() de goyave pour créer un hachage sur le prénom et le nom.

Le mécanisme de mise en cache ressemble à ceci:

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

La plupart du temps, j'obtiens des hits sur le prénom + le nom, mais quand ça manque, je recule en utilisant la StringUtils.getLevenshteinDistance() d'Apache pour essayer de la faire correspondre. Voici comment se déroule le flux logique correspondant:

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

    }

Il s'agit de la méthode 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;
}

Cela fonctionne très bien avec de simples fautes d'orthographe, fautes de frappe et noms abrégés (par exemple Mike-> Michael), mais lorsque je manque complètement l'un des noms entrants dans le cache, je finis par renvoyer une correspondance faussement positive. Pour éviter que cela ne se produise, j'ai défini la valeur min dans findClosetMatch() sur 15 (c'est-à-dire pas plus de 15 caractères désactivés); cela fonctionne la plupart du temps mais j'ai encore eu quelques erreurs d'appariement: Mike Thompson frappe sur Mike Thomas etc.

En dehors de trouver un moyen d'obtenir une clé primaire dans le fichier en cours de chargement, quelqu'un voit-il un moyen d'améliorer ce processus? D'autres algorithmes de correspondance pourraient vous aider ici?

20
Durandal

En regardant ce problème, je remarque quelques faits clés sur lesquels baser certaines améliorations:

Faits et observations

  1. Itérations maximales de 1000.
  2. 15 pour les sons de distance de Levenshtein vraiment élevé pour moi.
  3. Vous savez, en observant les données empiriquement, à quoi devrait ressembler votre correspondance floue (il existe de nombreux cas de correspondance floue et chacun dépend de pourquoi les données sont mauvaises).
  4. En construisant cette API, vous pouvez connecter de nombreux algorithmes, y compris le vôtre et d'autres comme Soundex , au lieu de dépendre d'un seul.

Exigences

J'ai interprété votre problème comme nécessitant les deux choses suivantes:

  1. Vous avez PersonDO objets que vous souhaitez rechercher via une clé basée sur le nom. Il semble que vous vouliez faire cela parce que vous avez besoin d'un PersonDO préexistant dont un existe par nom unique , et le même nom peut apparaître plus d'une fois dans votre boucle/workflow.
  2. Vous avez besoin d'une "correspondance floue" car les données entrantes ne sont pas pures. Aux fins de cet algorithme, nous supposerons que si un nom "correspond", il doit toujours utiliser le même PersonDO (dans en d'autres termes, l'identifiant unique d'une personne est son nom, ce qui n'est évidemment pas le cas dans la vie réelle, mais semble fonctionner pour vous ici).

La mise en oeuvre

Ensuite, regardons comment apporter quelques améliorations à votre code:

1. Nettoyage: manipulation inutile de hashcode.

Vous n'avez pas besoin de générer vous-même des codes de hachage. Cela confond un peu le problème.

Vous générez simplement un code de hachage pour la combinaison du prénom + nom. C'est exactement ce que ferait HashMap si vous lui donniez la chaîne concaténée comme clé. Donc, faites cela (et ajoutez un espace, juste au cas où nous voudrions inverser l'analyse en premier/dernier de la clé plus tard).

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. Nettoyage: créez une fonction de récupération pour effectuer la recherche.

Puisque nous avons changé la clé dans la carte, nous devons changer la fonction de recherche. Nous allons construire cela comme une mini-API. Si nous connaissions toujours exactement la clé (c'est-à-dire les identifiants uniques), nous utiliserions bien sûr Map.get Nous allons donc commencer avec cela, mais comme nous savons que nous devrons ajouter une correspondance floue, nous ajouterons un wrapper où cela peut se produire:

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

3. Créez vous-même un algorithme de correspondance floue en utilisant la notation.

Notez que puisque vous utilisez Guava, j'ai utilisé quelques commodités ici (Ordering, ImmutableList, Doubles, etc.).

Tout d'abord, nous voulons préserver le travail que nous faisons pour déterminer à quel point un match est proche. Faites cela avec un 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);
     }
   };
}

Ensuite, nous créons une méthode pour noter un nom générique. Nous devons noter le prénom et le nom séparément, car cela réduit le bruit. Par exemple, peu nous importe que le prénom corresponde à une partie du nom de famille - à moins que votre prénom ne se trouve accidentellement dans le champ du nom de famille ou vice versa, ce que vous devez tenir compte intentionnellement et non accidentellement (nous y reviendrons plus tard) .

Notez que nous n'avons plus besoin d'une "distance max levenshtein". C'est parce que nous les normalisons à la longueur, et nous choisirons la correspondance la plus proche plus tard. Les ajouts/modifications/suppressions de 15 caractères semblent très élevés, et puisque nous avons minimisé le nom/prénom vierge problème en marquant les noms séparément, nous pourrions probablement maintenant choisir un maximum de 3-4 si vous le souhaitez (en marquant n'importe quoi d'autre comme un 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));
}

Comme indiqué ci-dessus, vous pouvez connecter des algorithmes tiers ou d'autres algorithmes de correspondance de mots et bénéficier de la connaissance partagée de chacun d'eux.

Maintenant, nous parcourons toute la liste et notons chaque nom. Notez que j'ai ajouté une place pour les "réglages". Les ajustements peuvent inclure:

  • Inversion : Si le PersonDO est "Benjamin Franklin", mais la feuille CSV peut contenir "Franklin, Benjamin", alors vous voudrez corriger les noms inversés . Dans ce cas, vous voudrez probablement ajouter une méthode checkForReversal qui marquerait le nom à l'envers et prendrait ce score s'il est significativement plus élevé. S'il correspond exactement à l'inverse, vous lui donnez un score de 1,0 .
  • Abréviations : Vous voudrez peut-être donner au score un bonus si le prénom/nom correspond exactement et si l'autre est entièrement contenu dans le candidat (ou vice versa). Cela pourrait indiquer une abréviation, comme "Samantha/Sam".
  • Surnoms courants : vous pouvez ajouter un ensemble de surnoms connus ("Robert -> Bob, Rob, Bobby, Robby"), puis attribuer un nom au nom de la recherche tous et prendre le meilleur score. S'il correspond à l'un de ces éléments, vous lui donnerez probablement un score de 1,0 .

Comme vous pouvez le voir, la construction d'une série d'API nous donne des emplacements logiques pour facilement l'ajuster au contenu de notre cœur.

Sur l'alogrithme:

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);
}

Maintenant, nous modifions votre findClosestMatch pour obtenir juste le plus élevé de tous les matchs (jette NoSuchElementException si aucun dans la liste).

Ajustements possibles:

  • Vous voudrez peut-être vérifier si plusieurs noms ont marqué très près, et soit signaler les finalistes (voir ci-dessous), soit sauter la ligne pour un choix manuel plus tard.
  • Vous voudrez peut-être signaler le nombre d'autres correspondances (si vous avez un algorithme de notation très serré).

Code:

public Match findClosestMatch(String searchFirst, String searchLast) {
  List<Match> matches = findMatch(searchFirst, searchLast);

  // Tweak here

  return Match.SCORE_ORDER.max(list);
}

.. puis modifiez notre getter d'origine:

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. Signalez le "flou" différemment.

Enfin, vous remarquerez que findClosestMatch ne renvoie pas seulement une personne, il retourne un Match - C'est pour que nous puissions modifier le programme pour traiter les correspondances floues différemment des correspondances exactes.

Certaines choses que vous voudrez probablement faire avec ceci:

  • Rapport des suppositions: Enregistrez tous les noms qui correspondent en fonction du flou dans une liste afin que vous puissiez les signaler et qu'ils puissent être audités plus tard.
  • Validez d'abord: Vous voudrez peut-être ajouter un contrôle pour l'activer et le désactiver s'il utilise réellement les correspondances floues ou les rapporte simplement afin que vous puissiez masser le les données avant leur entrée.
  • Défensivité des données: Vous voudrez peut-être qualifier "incertaines" les modifications apportées à une correspondance floue. Par exemple, vous pouvez interdire toute "modification majeure" à un enregistrement Personne si la correspondance est floue.

Conclusion

Comme vous pouvez le voir, ce n'est pas trop de code pour le faire vous-même. Il est douteux qu'il y aura jamais une bibliothèque qui prédira les noms aussi bien que vous pourrez connaître les données vous-même.

Construire cela en morceaux comme je l'ai fait dans l'exemple ci-dessus vous permettra d'itérer et de tordre facilement et même de brancher des bibliothèques tierces pour améliorer votre marquer au lieu de dépendre entièrement d'eux - fautes et tout.

40
Nicole

Il n'y a pas de meilleure solution, de toute façon vous devez faire face à une sorte d'heuristique. Mais vous pouvez chercher une autre implémentation à distance Levenshtein (ou l'implémenter par vous-même). Cette implémentation doit donner des scores différents aux différentes opérations de caractères (insertion, suppression) pour différents caractères. Par exemple, vous pouvez attribuer des scores inférieurs aux paires de caractères proches du clavier. En outre, vous pouvez calculer dynamiquement le seuil de distance maximale en fonction d'une longueur de chaîne.

Et j'ai une astuce de performance pour vous. Chaque fois que vous calculez la distance de Levenshtein, n * m opérations sont effectuées, où n et m sont des longueurs de chaînes. Il y a automate Levenshtein que vous construisez une fois puis évaluez très rapidement pour chaque chaîne. Soyez prudent, car NFA est très coûteux à évaluer, vous devez d'abord le convertir en DFA.

Peut-être que vous devriez jeter un œil à Lucene . J'espère qu'il comprend toutes les capacités de recherche floue dont vous avez besoin. Vous pouvez même utiliser votre recherche de texte intégral SGBD, si elle est prise en charge. Par exemple, PostgreSQL prend en charge le texte intégral.

2
Alexey Andreev
  1. Utilisez-vous db pour effectuer la recherche? Utiliser une expression régulière dans votre sélection ou utiliser l'opérateur LIKE

  2. Analysez votre base de données et essayez de construire ou Huffman-tree ou plusieurs table pour effectuer une recherche par valeur-clé.

2
Giorgio Desideri

Voici ce que j'ai fait avec un cas d'utilisation similaire:

  • Faites correspondre le prénom et le nom séparément, cela fera une correspondance plus précise et éliminera certains des faux positifs:
 distance ("ab", "ac") est de 33% 
 max (distance ("a", "a"), distance ("b", "c")) est 100% 
  • Basez vos min critères de distance sur la longueur des chaînes d'entrée, c'est-à-dire 0 est pour les chaînes de moins de 2 symboles, 1 est pour les chaînes de moins de 3 symboles.
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
...

Ces deux devraient fonctionner pour votre entrée.

2
Andrey Chaschev