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?
En regardant ce problème, je remarque quelques faits clés sur lesquels baser certaines améliorations:
J'ai interprété votre problème comme nécessitant les deux choses suivantes:
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.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).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:
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 .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:
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:
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.
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.
Utilisez-vous db pour effectuer la recherche? Utiliser une expression régulière dans votre sélection ou utiliser l'opérateur LIKE
Analysez votre base de données et essayez de construire ou Huffman-tree ou plusieurs table pour effectuer une recherche par valeur-clé.
Voici ce que j'ai fait avec un cas d'utilisation similaire:
distance ("ab", "ac") est de 33% max (distance ("a", "a"), distance ("b", "c")) est 100%
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.