web-dev-qa-db-fra.com

Groupement efficace des similarités de chaînes

Paramètres : J'ai des données sur les personnes et les noms de leurs parents, et je souhaite trouver des frères et sœurs (personnes portant des noms de parents identiques).

 pdata<-data.frame(parents_name=c("peter pan + marta steward",
                                 "pieter pan + marta steward",
                                 "armin dolgner + jane johanna dough",
                                 "jack jackson + sombody else"))

Le résultat attendu ici serait une colonne indiquant que les deux premières observations appartiennent à la famille X, tandis que les troisième et quatrième colonnes appartiennent à une famille distincte. Par exemple:

person_id    parents_name                           family_id
1            "peter pan + marta steward",           1
2            "pieter pan + marta steward",          1
3            "armin dolgner + jane johanna dough",  2
4            "jack jackson + sombody else"          3

Approche actuelle : Je suis flexible en ce qui concerne la métrique de distance. Actuellement, j'utilise Levenshtein edit-distance pour correspondre à obs, ce qui permet des différences de deux caractères. Mais d'autres variantes telles que "la plus grande sous-chaîne commune" conviendraient bien si elles fonctionnent plus rapidement.

Pour les plus petits échantillons, j'utilise stringdist::stringdist dans une boucle ou stringdist::stringdistmatrix, mais cela devient de moins en moins efficace à mesure que la taille de l'échantillon augmente.

La version de la matrice explose dès qu'une certaine taille d'échantillon est utilisée. Ma tentative terriblement inefficace de bouclage est ici:

#create data of the same complexity using random last-names
#(4mio obs and ~1-3 kids per parents) 
pdata<-data.frame(parents_name=paste0(rep(c("peter pan + marta ",
                                "pieter pan + marta ",
                                "armin dolgner + jane johanna ",
                                "jack jackson + sombody "),1e6),stringi::stri_Rand_strings(4e6, 5)))

for (i in 1:nrow(pdata)) {
  similar_fatersname0<-stringdist::stringdist(pdata$parents_name[i],pdata$parents_name[i:nrow(pdata)],nthread=4)<2
  #[create grouping indicator]
}

Ma question : Il devrait y avoir des gains d’efficacité substantiels, par exemple parce que je pouvais arrêter de comparer les chaînes une fois que je les avais trouvées suffisamment différentes en quelque chose de plus facile à évaluer, par exemple. longueur de chaîne ou premier mot. La variante de longueur de chaîne fonctionne déjà et réduit la complexité d'un facteur ~ 3. Mais c'est de loin trop peu. Toute suggestion visant à réduire le temps de calcul est appréciée.

Remarques :

  • Les chaînes sont en réalité en unicode et non en alphabet latin (Devnagari)
  • Le pré-traitement pour supprimer les caractères non utilisés, etc. est effectué
10
sheß

Il y a deux défis:  

A. L’exécution parallèle de la distance de Levenstein - au lieu d’une boucle séquentielle

B. Le nombre de comparaisons: si notre liste de sources contient 4 millions d'entrées, nous devrions théoriquement utiliser 16 000 milliards de mesures de distance de Levenstein, ce qui est irréaliste, même si nous résolvons le premier défi. 

Pour que mon langage soit clair, voici nos définitions

  • nous voulons mesurer la distance de Levenstein entre les expressions.
  • chaque expression a deux sections, le nom complet parent A et le nom complet parent B, séparés par un signe plus
  • l'ordre des sections est important (deux expressions (1, 2) sont identiques si le parent A de l'expression 1 = le parent A de l'expression 2 et le parent B ou l'expression 1 = le parent B de l'expression 2. Les expressions ne seront pas considérées comme identiques si le parent A de l'expression 1 = parent B de l'expression 2 et parent B de l'expression 1 = parent A de l'expression 2)
  • une section (ou un nom complet) est une série de mots séparés par des espaces ou des tirets et correspondant au prénom et au nom de famille d'une personne 
  • nous supposons que le nombre maximum de mots dans une section est de 6 (votre exemple comporte des sections de 2 ou 3 mots, je suppose que nous pouvons en avoir jusqu'à 6) la séquence de mots dans une section a une importance (la section est toujours un prénom suivi d'un nom de famille et jamais du nom de famille en premier, par exemple Jack John et John Jack sont deux personnes différentes).
  • il y a 4 millions d'expressions
  • les expressions sont supposées contenir uniquement des caractères anglais. Les nombres, les espaces, la ponctuation, les tirets et tout caractère non anglais peuvent être ignorés
  • nous supposons que les correspondances faciles sont déjà effectuées (comme les correspondances d'expression exactes) et nous n'avons pas à rechercher des correspondances exactes 

Techniquement, l’objectif est de trouver une série d’expressions correspondantes dans la liste des 4 millions d’expressions. Deux expressions sont considérées comme des expressions correspondantes si leur distance de Levenstein est inférieure à 2. 

En pratique, nous créons deux listes, qui sont des copies exactes de la liste initiale de 4 millions d'expressions. Nous appelons ensuite la liste de gauche et la liste de droite. Un identifiant d'expression est attribué à chaque expression avant la duplication de la liste. Notre objectif est de rechercher dans la liste de droite les entrées dont la distance de Levenstein est inférieure à 2 par rapport aux entrées de la liste de gauche, à l'exclusion de la même entrée (même expression). id). 

Je suggère une approche en deux étapes pour résoudre les deux problèmes séparément. La première étape réduira la liste des expressions correspondantes possibles, la seconde simplifiera la mesure de distance de Levenstein puisque nous ne nous intéressons qu'aux expressions très proches. La technologie utilisée est un serveur de base de données traditionnel, car nous devons indexer les ensembles de données pour améliorer les performances. 

CHALLENGE A  

Le défi A consiste à réduire le nombre de mesures de distance. Nous partons d'un maximum d'env. 16 billions de dollars (4 millions à la puissance de deux) et nous ne devrions pas dépasser quelques dizaines ou centaines de millions. La technique à utiliser ici consiste à rechercher au moins un mot similaire dans l'expression complète. Selon la manière dont les données sont distribuées, cela réduira considérablement le nombre de paires d'appariement possibles. Alternativement, en fonction de la précision requise du résultat, nous pouvons également rechercher des paires avec au moins deux mots similaires ou avec au moins la moitié de mots similaires. 

Techniquement, je suggère de mettre la liste des expressions dans un tableau. Ajoutez une colonne d'identité pour créer un identifiant unique par expression et créez des colonnes de 12 caractères. Ensuite, analysez les expressions et mettez chaque mot de chaque section dans une colonne séparée. Cela ressemblera à (je n'ai pas représenté toutes les 12 colonnes, mais l'idée est ci-dessous):

|id | expression | sect_a_w_1 | sect_a_w_2 | sect_b_w_1 |sect_b_w_2 |
|1 | peter pan + marta steward | peter | pan | marta |steward      |

Il y a des colonnes vides (car il y a très peu d'expressions avec 12 mots) mais cela n'a pas d'importance. 

Ensuite, nous répliquons la table et créons un index sur chaque colonne sect .... Nous exécutons 12 jointures qui tentent de trouver des mots similaires, quelque chose comme 

SELECT L.id, R.id 
FROM left table L JOIN right table T 
ON L.sect_a_w_1 = R.sect_a_w_1
AND L.id <> R.id 

Nous collectons la sortie dans 12 tables temporaires et exécutons une requête d'union de ces 12 tables pour obtenir une liste courte de toutes les expressions ayant une expression correspondante correspondante avec au moins un mot identique. C'est la solution à notre défi A. Nous avons maintenant une courte liste des paires les plus probables. Cette liste contiendra des millions d'enregistrements (paires d'entrées gauche et droite), mais pas des milliards. 

DÉFI B  

Le défi B a pour objectif de traiter une distance de Levenstein simplifiée par lots (au lieu de les exécuter en boucle). Tout d’abord, nous devrions nous mettre d’accord sur une distance de Levenstein simplifiée. Tout d'abord, nous convenons que la distance de Levenstein de deux expressions est la somme de la distance de Levenstein de tous les mots des deux expressions qui ont le même indice. Je veux dire que la distance de Levenstein entre deux expressions est la distance de leurs deux premiers mots, plus la distance de leurs deux secondes mots, etc. Deuxièmement, nous devons inventer une distance de Levenstein simplifiée. Je suggère d'utiliser l'approche de n-grammes avec seulement des grammes de 2 caractères qui ont une différence absolue d'indice inférieure à 2. 

par exemple. la distance entre peter et pieter est calculée comme ci-dessous 

Peter       
1 = pe          
2 = et          
3 = te          
4 = er
5 = r_           

Pieter
1 = pi
2 = ie
3 = et
4 = te
5 = er
6 = r_ 

Peter et Pieter ont 4 grammes communs de 2 grammes avec une différence absolue d'indice inférieure à 2 'et', 'te', 'er', 'r_'. Il y a 6 2 grammes possibles dans le plus grand des deux mots, la distance est alors de 6-4 = 2 - La distance de Levenstein serait également de 2 car il y a un mouvement de "eter" et l'insertion d'une lettre "i".

C'est une approximation qui ne fonctionnera pas dans tous les cas, mais je pense que dans notre cas, cela fonctionnera très bien. Si nous ne sommes pas satisfaits de la qualité des résultats, nous pouvons essayer avec 3 grammes ou 4 grammes ou autoriser une différence de séquence supérieure à 2 grammes. Mais l’idée est d’exécuter beaucoup moins de calculs par paire que dans l’algorithme de Levenstein traditionnel. 

Ensuite, nous devons convertir cela en une solution technique. Ce que j’ai fait auparavant est le suivant: Commencez par isoler les mots: comme il suffit de mesurer la distance entre les mots, puis de faire la somme de ces distances par expression, nous pouvons encore réduire le nombre de calculs en effectuant une sélectionnez sur la liste de mots (nous avons déjà préparé la liste de mots de la section précédente). 

Cette approche nécessite une table de mappage qui garde une trace de l'ID d'expression, de l'ID de section, de l'ID de Word et du numéro de séquence de Word pour Word, afin que la distance de l'expression d'origine puisse être calculée à la fin du processus. 

Nous avons ensuite une nouvelle liste beaucoup plus courte qui contient une jointure croisée de tous les mots pour lesquels la mesure de distance de 2 grammes est pertinente. Ensuite, nous voulons traiter par lots cette mesure de distance de 2 grammes, et je suggère de le faire dans une jointure SQL. Cela nécessite une étape de prétraitement qui consiste à créer une nouvelle table temporaire qui stocke chaque gramme sur une ligne distincte et qui garde une trace de l'identifiant du mot, de la séquence du mot et du type de section. 

Techniquement, cela se fait en découpant la liste de mots en utilisant une série (ou une boucle) de sélection de sous-chaîne, comme ceci (en supposant que les tables de liste de Word - il y a deux copies, une à gauche et une à droite - contiennent deux colonnes Word_id et Word):

INSERT INTO left_gram_table (Word_id, gram_seq, gram)
SELECT Word_id, 1 AS gram_seq, SUBSTRING(Word,1,2) AS gram
FROM left_Word_table 

Et alors 

INSERT INTO left_gram_table (Word_id, gram_seq, gram)
SELECT Word_id, 2 AS gram_seq, SUBSTRING(Word,2,2) AS gram
FROM left_Word_table 

Etc.

Quelque chose qui fera «steward» ressembler à ceci (supposons que l'identifiant Word est 152) 

|  pk  | Word_id | gram_seq | gram | 
|  1   |  152       |  1          | st |
|  2   |  152       |  2          | te |
|  3   |  152       |  3          |  ew |
|  4   |  152       |  4          |  wa |
|  5   |  152       |  5          |  ar |
|  6   |  152       |  6          |  rd |
|  7   |  152       |  7          |  d_ |

N'oubliez pas de créer un index sur les colonnes Word_id, Gram et Gram_seq. La distance peut être calculée avec une jointure de la liste des grammes gauche et droite, où ON ressemble à 

ON L.gram = R.gram 
AND ABS(L.gram_seq + R.gram_seq)< 2 
AND L.Word_id <> R.Word_id 

La distance est la longueur du plus long des deux mots moins le nombre de grammes correspondants. SQL est extrêmement rapide pour faire une telle requête, et je pense qu'un simple ordinateur avec 8 Go de RAM ferait facilement plusieurs centaines de millions de lignes dans un délai raisonnable.

Ensuite, il suffit de rejoindre la table de mappage pour calculer la somme de la distance mot à mot dans chaque expression, afin d’obtenir la distance totale expression à expression. 

7
JeromeE

De toute façon, vous utilisez le package stringdist, est-ce que stringdist::phonetic() répond à vos besoins? Il calcule le code soundex pour chaque chaîne, par exemple:

phonetic(pdata$parents_name)
[1] "P361" "P361" "A655" "J225"

Soundex est une méthode éprouvée (presque 100 ans) de hachage de noms, ce qui signifie que vous n'avez pas besoin de comparer chaque paire d'observations. 

Vous voudrez peut-être aller plus loin et faire soundex sur le prénom et le nom séparément pour père et mère.

6
Neal Fultz

Ma suggestion est d'utiliser une approche basée sur la science des données pour identifier uniquement les noms similaires (mêmes grappes) à comparer à l'aide de stringdist.

J'ai modifié un peu le code générant "parents_name" en ajoutant plus de variabilité dans les premier et deuxième noms dans un scénario proche de la réalité. 

num<-4e6
#Random length
random_l<-round(runif(num,min = 5, max=15),0)
#Random strings in the first and second name
parent_Rand_first<-stringi::stri_Rand_strings(num, random_l)
order<-sample(1:num, num, replace=F)
parent_Rand_second<-parent_Rand_first[order]
#Paste first and second name
parents_name<-paste(parent_Rand_first," + ",parent_Rand_second)
parents_name[1:10]

Ici commence l’analyse réelle, extrait d’abord les caractéristiques des noms tels que longueur globale, longueur de la première, longueur de la seconde, nombre de voyelles et consonantes à la fois dans le premier et le deuxième nom (et dans tout autre intérêt).

Après cela, liez toutes ces fonctionnalités et classez le data.frame dans un grand nombre de clusters (par exemple 1000)

features<-cbind(nchars,nchars_first,nchars_second,nvowels_first,nvowels_second,nconsonants_first,nconsonants_second)
n_clusters<-1000
clusters<-kmeans(features,centers = n_clusters)

Appliquer stringdistmatrix uniquement à l'intérieur de chaque cluster (contenant deux noms similaires) 

dist_matrix<-NULL
for(i in 1:n_clusters)
{
  cluster_i<-clusters$cluster==i

  parents_name<-as.character(parents_name[cluster_i])

  dist_matrix[[i]]<-stringdistmatrix(parents_name,parents_name,"lv")
}

Dans dist_matrix, vous avez la distance entre chaque élément du cluster et vous pouvez affecter le family_id en utilisant cette distance.

Pour calculer la distance dans chaque cluster (dans cet exemple), le code prend environ 1 seconde (selon la dimension du cluster). Toutes les distances sont calculées en 15 minutes. 

WARNING: dist_matrix se développe très rapidement. Dans votre code, il est préférable de l’analyser à l’intérieur de di pour extraire famyli_id en boucle, puis de le supprimer.

5
Terru_theTerror

J'ai fait face au même problème de performance il y a quelques années. Je devais faire correspondre les doublons des gens en fonction de leurs noms dactylographiés. Mon jeu de données avait 200 000 noms et l’approche matricielle a explosé. Après avoir cherché pendant un jour sur une meilleure méthode, la méthode que je propose ici a fait le travail pour moi en quelques minutes:

library(stringdist)

parents_name <- c("peter pan + marta steward",
            "pieter pan + marta steward",
            "armin dolgner + jane johanna dough", 
            "jack jackson + sombody else")

person_id <- 1:length(parents_name)

family_id <- vector("integer", length(parents_name))


#Looping through unassigned family ids
while(sum(family_id == 0) > 0){

  ids <- person_id[family_id == 0]

  dists <- stringdist(parents_name[family_id == 0][1], 
                      parents_name[family_id == 0], 
                      method = "lv")

  matches <- ids[dists <= 3]

  family_id[matches] <- max(family_id) + 1
}

result <- data.frame(person_id, parents_name, family_id)

De cette façon, la while comparera moins de correspondances à chaque itération. À partir de cela, vous pouvez implémenter différents boosters de performances, comme par exemple filtrer les noms avec la même première lettre avant de comparer, etc.

2
Anderson Neisse

Vous pouvez vous améliorer en ne comparant pas tous les couples de lignes. Créez plutôt une nouvelle variable qui vous aidera à décider si elle vaut la peine d'être comparée.

Par exemple, créez une nouvelle variable "score" contenant la liste ordonnée de lettres utilisée dans parents_name (par exemple, si "peter pan + marta steward", le score sera "ademnprstw") et calculez la distance uniquement entre les lignes correspondant au score. .

Bien sûr, vous pouvez trouver un score qui correspond mieux à vos besoins et vous améliorer un peu pour permettre la comparaison lorsque toutes les lettres utilisées ne sont pas courantes. 

Faire des groupes d'équivalence sur une relation non transitive n'a pas de sens. Si A est comme B et B est comme C, mais A n'est pas comme C, comment feriez-vous des familles à partir de cela? Utiliser quelque chose comme soundex (c'était l'idée de Neal Fultz, pas la mienne) semble être la seule option significative et cela résoudra également votre problème de performance.

1
Antonín Lejsek

Ce que j’ai utilisé pour réduire les permutations impliquées dans ce type de correspondance de nom, c’est de créer une fonction qui compte les syllabes dans le nom (nom de famille) impliqué. Puis stockez-le dans la base de données, en tant que valeur prétraitée. Cela devient une fonction Syllable Hash .

Ensuite, vous pouvez choisir de regrouper des mots avec le même nombre de syllabes les uns que les autres. (Bien que j'utilise des algorithmes permettant une différence de 1 ou 2 syllabes, qui peuvent être présentés comme des fautes d'orthographe/de frappe légitimes ... Mais mes recherches ont révélé que 95% des fautes d'orthographe partagent le même nombre de syllabes)

Dans ce cas, Peter et Pieter auraient le même nombre de syllabes (2), mais Jones et Smith n'en ont pas (ils en ont 1). (Par exemple)

Si votre fonction n'obtient pas 1 syllabe pour Jones, vous devrez peut-être augmenter votre tolérance pour permettre au moins une différence de syllabe dans le groupe de fonctions Syllable Hash que vous utilisez. (Pour prendre en compte des résultats de fonction de syllabe incorrects et pour saisir correctement le nom de famille correspondant dans le groupe)

Ma fonction de comptage de syllabes peut ne pas s'appliquer complètement - vous devrez peut-être composer avec des ensembles de lettres non anglaises ... (Je n'ai donc pas collé le code ... C'est en C de toute façon) Remarquez que la fonction de comptage de syllabes n'a pas être précis en termes de VRAI compte de syllabes; il doit simplement agir comme une fonction de hachage fiable - ce qui est le cas. De loin supérieur à SoundEx qui repose sur la précision de la première lettre.

Essayez-le, vous serez peut-être surpris de l’amélioration que vous obtiendrez en implémentant une fonction Syllable Hash. Vous devrez peut-être demander de l'aide à SO pour que cette fonction soit disponible dans votre langue.

1
Grantly

Si je comprends bien, vous voulez comparer chaque paire de parents (chaque ligne du cadre de données parent_name) avec toutes les autres paires (lignes) et conserver les lignes dont la distance de Levenstein est inférieure ou égale à 2.

J'ai écrit le code suivant pour le début:

pdata<-data.frame(parents_name=c("peter pan + marta steward",
                                 "pieter pan + marta steward",
                                 "armin dolgner + jane johanna dough",
                                 "jack jackson + sombody else"))

fuzzy_match <- list()
system.time(for (i in 1:nrow(pdata)){
  fuzzy_match[[i]] <- cbind(pdata, parents_name_2 = pdata[i,"parents_name"],
                            dist = as.integer(stringdist(pdata[i,"parents_name"], pdata$parents_name)))
  fuzzy_match[[i]] <- fuzzy_match[[i]][fuzzy_match[[i]]$dist <= 2,]
})
fuzzy_final <- do.call(rbind, fuzzy_match)

Est-ce qu'il retourne ce que tu voulais?

0
Mislav

il reproduit votre sortie, je suppose que vous devrez décider des critères de correspondance partiels, j’ai conservé les accords par défaut

pdata$parents_name<-as.character(pdata$parents_name)
x00<-unique(lapply(pdata$parents_name,function(x) agrep(x,pdata$parents_name)))
x=c()
for (i in 1:length(x00)){
  x=c(x,rep(i,length(x00[[i]])))
}
pdata$person_id=seq(1:nrow(pdata))
pdata$family_id=x
0
Antonis