【问题标题】:Efficient string similarity grouping高效的字符串相似度分组
【发布时间】:2018-06-11 23:45:38
【问题描述】:

设置: 我有有关人员及其父母姓名的数据,我想找到兄弟姐妹(父母姓名相同的人)。

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

此处的预期输出将是一列,表明前两个观测值属于 X 族,而第三列和第四列分别属于一个单独的族。例如:

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

目前的做法: 我对距离度量很灵活。目前,我使用 Levenshtein 编辑距离来匹配 obs,允许两个字符的差异。但是其他变体,例如“最大公共子字符串”,如果它们运行得更快,那就没问题了。

对于较小的子样本,我在循环中使用 stringdist::stringdiststringdist::stringdistmatrix,但随着样本量的增加,效率会越来越低。

一旦使用一定的样本量,矩阵版本就会爆炸。我非常低效的循环尝试在这里:

#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]
}

我的问题:应该有显着的效率提升,例如因为一旦我发现它们在更容易评估的东西上存在很大差异,我就可以停止比较字符串,例如。字符串长度,或第一个单词。字符串长度变体已经起作用,并将复杂性降低了约 3 倍。但这远远太少了。任何减少计算时间的建议都值得赞赏。

备注

  • 字符串实际上是 unicode,而不是拉丁字母 (Devnagari)
  • 已完成删除未使用字符等的预处理

【问题讨论】:

  • 您的 for 循环不工作。此外,您应该提供您正在使用的规模的示例数据...
  • 希望您明白,出于保密原因,我无法提供实际数据
  • 问题:检查pdata$parents_name[1:i] 的距离不是更好吗?第一项将始终是它自己的family_id(因为尚未分配其他家庭ID)。那么第二个项目只需要与第一个项目进行比较,因为其他项目都没有分配family_id。
  • 如果这些示例与您的实际情况足够接近,您可能不需要计算所有成对距离,如果它们之间的距离小于 4,您可能会认为 2 个字符串具有相同的 family_id,并且将 family_id 的第一个实例视为规范实例,如果您有相当数量的 family_id 实例,它会快得多。对值得计算的距离进行额外的预过滤可以通过拆分“+”并消除长度非常不同的对(比如超过 3 个字符)来完成。

标签: r string performance levenshtein-distance


【解决方案1】:

我用来减少这种名称匹配中涉及的排列的方法是创建一个函数来计算所涉及的名称(姓氏)中的音节。然后将其作为预处理值存储在数据库中。这变成了一个音节散列函数。

然后您可以选择将具有相同音节数的单词组合在一起。 (虽然我使用允许 1 或 2 个音节差异的算法,这可能表现为合法的拼写/错字错误……但我的研究发现,95% 的拼写错误共享相同数量的音节)

在这种情况下,PeterPieter 将具有相同的音节数 (2),但 JonesSmith 不一样(它们有 1)。 (例如)

如果您的函数没有为 Jones 获得 1 个音节,那么您可能需要增加容差以允许您使用的 Syllable Hash 函数分组中至少有 1 个音节差异。 (考虑不正确的音节函数结果,并在分组中正确捕获匹配的姓氏)

我的音节计数功能可能不完全适用 - 因为您可能需要处理非英文字母集...(所以我没有粘贴代码...无论如何它都是 C 中的)请注意 - 音节计数功能在 TRUE 音节计数方面不必准确;它只需要充当可靠的散列函数 - 它确实如此。远优于依赖首字母准确的 SoundEx。

试一试,您可能会惊讶于实现 Syllable Hash 函数所获得的改进。您可能需要向 SO 寻求帮助,以便将功能转换为您的语言。

【讨论】:

    【解决方案2】:

    它会重现你的输出,我想你必须决定部分匹配标准,我保留了默认的 agrep

    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
    

    【讨论】:

      【解决方案3】:

      在非传递关系上建立等价组没有意义。如果AB 类似,BC 类似,但AC 不同,您将如何从中建立家庭?使用 soundex 之类的东西(这是 Neal Fultz 的想法,不是我的)似乎是唯一有意义的选择,它也可以解决您的性能问题。

      【讨论】:

      • 传递性确实是个问题。然而,从对数据的第一次检查来看,名称似乎有很大不同,因此如果 A~=B 和 B~=C,则视为 A~=C 仍然可以。这可以在一个简单的后处理步骤中处理
      【解决方案4】:

      几年前我也遇到过同样的性能问题。我必须根据他们输入的名字来匹配人们的重复项。我的数据集有 20 万个名称,矩阵方法爆炸式增长。在寻找了一天更好的方法之后,我在这里提出的方法在几分钟内就为我完成了工作:

      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)
      

      这样while 将在每次迭代中比较更少的匹配项。由此,您可以实现不同的性能提升器,例如在比较之前过滤具有相同首字母的名称等。

      【讨论】:

        【解决方案5】:

        您仍然在使用stringdist 包,stringdist::phonetic() 是否适合您的需求?它计算每个字符串的 soundex 代码,例如:

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

        Soundex 是一种久经考验的方法(几乎有 100 年的历史),用于散列名称,这意味着您无需比较每一对观察结果。

        您可能想更进一步,分别对父亲和母亲的名字和姓氏进行 soundex。

        【讨论】:

        • 好主意,但我的名字是 Devnagari/Nepali,我相信 soundex 不能很好地处理这个问题
        • 一般的想法应该可行,您只需要自己指定元音和辅音。
        • 或者您可以先尝试将您的数据音译为英文,例如使用github.com/prabhasp/Nepali-Language-Tools/blob/master/… 作为预处理步骤。
        【解决方案6】:

        有两个挑战:

        A. Levenstein距离的并行执行——而不是顺序循环

        B.比较次数:如果我们的源列表有 400 万个条目,理论上我们应该运行 16 万亿个 Levenstein 距离度量,即使我们解决了第一个挑战,这是不现实的。

        为了清楚地说明我对语言的使用,这里是我们的定义

        • 我们想要测量表达式之间的 Levenstein 距离。
        • 每个表达式都有两个部分,父 A 全名和父 B 全名,用加号分隔
        • 部分的顺序很重要(即如果表达式 1 的父 A = 表达式 2 的父 A 和父 B 或表达式 1= 表达式 2 的父 B,则两个表达式 (1, 2) 相同。表达式将不被考虑如果表达式 1 的父 A = 表达式 2 的父 B 和表达式 1 的父 B = 表达式 2 的父 A,则相同
        • 一个部分(或全名)是一系列单词,由空格或破折号分隔,对应一个人的名字和姓氏
        • 我们假设一个部分中的最大单词数为 6(您的示例有 2 或 3 个单词的部分,我假设我们最多可以有 6 个) 部分中的单词顺序很重要(部分始终是名字后跟姓氏,而不是姓氏在前,例如 Jack John 和 John Jack 是两个不同的人)。
        • 有 400 万个表达式
        • 假定表达式仅包含英文字符。数字、空格、标点符号、破折号和任何非英文字符都可以忽略
        • 我们假设简单匹配已经完成(就像完全匹配的表达式),我们不必搜索完全匹配

        从技术上讲,目标是在 400 万个表达式列表中找到一系列匹配的表达式。如果两个表达式的 Levenstein 距离小于 2,则认为它们是匹配表达式。

        实际上,我们创建了两个列表,它们是最初的 400 万个表达式列表的精确副本。我们称之为左列表和右列表。在复制列表之前,每个表达式都被分配了一个表达式 ID。 我们的目标是在 Right 列表中找到与 Left 列表条目之间的 Levenstein 距离小于 2 的条目,不包括相同的条目(相同的表达式 id)。

        我建议采用两步法分别解决这两个挑战。第一步将减少可能匹配表达式的列表,第二步将简化 Levenstein 距离测量,因为我们只查看非常接近的表达式。使用的技术是任何传统的数据库服务器,因为我们需要索引数据集以提高性能。

        挑战 A

        挑战 A 包括减少距离测量的次数。我们从最大约开始。 16万亿(400万的2次方),我们不应该超过几千万或几亿。 此处使用的技术包括在完整表达式中搜索至少一个相似词。根据数据的分布方式,这将大大减少可能匹配对的数量。或者,根据所需的结果准确性,我们还可以搜索具有至少两个相似词或至少一半相似词的对。

        从技术上讲,我建议将表达式列表放在一个表格中。添加一个标识列来为每个表达式创建一个唯一的 id,并创建 12 个字符的列。然后解析表达式并将每个部分的每个单词放在单独的列中。这看起来像(我没有代表所有 12 列,但想法如下):

        |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      |
        

        有空列(因为很少有 12 个单词的表达式)但没关系。

        然后我们复制表并在每个 sect... 列上创建一个索引。 我们运行了 12 个连接,试图找到相似的词,比如

        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 
        

        我们在 12 个临时表中收集输出,并对 12 个表运行联合查询,以获取所有表达式的简短列表,这些表达式具有至少一个相同单词的潜在匹配表达式。这是我们挑战 A 的解决方案。我们现在有一个最可能匹配对的简短列表。该列表将包含数百万条记录(左右条目对),但不会包含数十亿条记录。

        挑战 B

        挑战 B 的目标是批量处理简化的 Levenstein 距离(而不是循环运行)。 首先,我们应该就什么是简化的 Levenstein 距离达成一致。 首先我们同意两个表达式的列文斯坦距离是这两个表达式的所有具有相同索引的词的列文斯坦距离之和。我的意思是两个表达式的 Levenstein 距离是它们的两个第一个词的距离,加上它们的两个第二个词的距离,等等。 其次,我们需要发明一个简化的 Levenstein 距离。我建议使用 n-gram 方法,其中只有 2 个字符的克数,其索引绝对差小于 2 。

        例如peter 和 pieter 之间的距离计算如下

        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 和 Pieter 有 4 个常见的 2-gram,其索引绝对差小于 2 'et','te','er','r_'。两个单词中最大的有 6 个可能的 2-gram,那么距离是 6-4 = 2 - Levenstein 距离也将是 2,因为有一个“eter”移动和一个字母插入“i”。

        这是一个近似值,并非在所有情况下都有效,但我认为在我们的情况下它会很好地工作。如果我们对结果的质量不满意,我们可以尝试使用 3-gram 或 4-gram,或者允许大于 2 gram 的序列差异。但这个想法是每对执行的计算量要比传统的 Levenstein 算法少得多。

        然后我们需要将其转化为技术解决方案。我之前所做的如下: 首先隔离单词:因为我们只需要测量单词之间的距离,然后对每个表达式的这些距离求和,我们可以通过在单词列表上运行不同的选择来进一步减少计算次数(我们已经准备了上一节中的单词)。

        这种方法需要一个映射表来跟踪单词的表达id、节id、词id和词序号,以便在处理结束时计算原始表达距离。

        然后我们有一个更短的新列表,其中包含与 2-gram 距离度量相关的所有单词的交叉连接。 然后我们要批量处理这个 2 克的距离测量,我建议在 SQL 连接中进行。这需要一个预处理步骤,包括创建一个新的临时表,该表将每个 2-gram 存储在一个单独的行中 - 并跟踪单词 Id、单词序列和节类型

        从技术上讲,这是通过使用一系列(或循环)子字符串选择对单词列表进行切片来完成的,如下所示(假设单词列表表 - 有两个副本,一个左侧和一个右侧 - 包含 2 列 word_id 和词):

        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 
        

        然后

        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 
        

        等等

        会让“管家”看起来像这样的东西(假设单词 id 是 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_ |
        

        别忘了在word_id、gram和gram_seq列上创建索引,距离可以用左右gram列表的join计算,其中ON的样子

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

        距离是两个单词中最长的单词的长度减去匹配的克数。 SQL 进行这样的查询非常快,我认为一台具有 8 GB RAM 的简单计算机可以在合理的时间范围内轻松完成数亿行。

        然后只需加入映射表,计算每个表达式中词到词的距离之和,得到总的表达式到表达式的距离。

        【讨论】:

        • 顺便说一句,有一个提高性能的解决方案,如果这仍然太慢:用数字替换 2-grams - 在旁边构建所有可能的 2-grams 的映射表。由于存在少量可能的 2-gram(假设我们只处理 2-gram),使用 SMALLINT 而不是 CHAR(2) 将显着提高 JOIN 查询性能。我们只需要计算匹配的 2-gram 的数量,我们不需要知道它们最初是由什么字母组成的。
        • FWIW 4^2 = 1600 万(不是万亿)成对比较不是正确的数字。它应该是 4*(4-1)/2 = 600 万次比较。 4*(4-1) 因为不需要自我比较,除以二是因为比较(应该)是无序的。还有很多工作要做,但远少于 16 个。
        【解决方案7】:

        我的建议是使用数据科学方法来识别相似(相同的集群)名称,以便使用 stringdist 进行比较。

        我对生成“parents_name”的代码进行了一些修改,在接近现实的场景中增加了名字和名字的可变性。

        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]
        

        从这里开始真正的分析,首先从名称中提取特征,例如全局长度、第一个长度、第二个长度、名字和第二个名字中元音和辅音的数量(以及任何其他感兴趣的)。

        之后绑定所有这些特征,并在大量集群(例如 1000 个)中对 data.frame 进行聚类

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

        仅在每个集群内应用 stringdistmatrix(包含相似的名称)

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

        在 dist_matrix 中,您拥有集群中每个元素之间的距离,您可以使用此距离分配 family_id。

        要计算每个集群中的距离(在此示例中),代码大约需要 1 秒(取决于集群的维度),在 15 分钟内计算所有距离。

        警告:dist_matrix 增长非常快,如果您在 di 内部分析它以循环提取 famyli_id 然后您可以丢弃它,则在您的代码中会更好。

        【讨论】:

          【解决方案8】:

          如果我猜对了,您希望将每个父对(parent_name 数据框中的每一行)与所有其他对(行)进行比较,并保持 Levenstein 距离小于或等于 2 的行。

          一开始我写了以下代码:

          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)
          

          它会返回你想要的吗?

          【讨论】:

          • 确实如此(除了不是我的问题中指定的格式,但没关系)。但是,您的解决方案效率不高,一旦使用几百万次观察就会崩溃。
          • 使用并行计算(foreach 循环)可以使其更快。它坏了是什么意思?
          • 分解我的意思是需要 100000 年才能完成,您可以使用我在问题中提供的第二个代码来获得更大的数据集,然后您会发现您的代码执行得非常糟糕。
          【解决方案9】:

          您可以通过不比较所有几行来改进。 相反,创建一个新变量有助于确定是否值得比较。

          例如,创建一个新变量“score”,其中包含在 parents_name 中使用的字母的有序列表(例如,如果“peter pan + marta Steward”,那么得分将为“ademnprstw”),并仅计算其中的行之间的距离分数匹配。

          当然,您可以找到更符合您需要的分数,并在并非所有使用的字母都通用时进行一些改进以进行比较..

          【讨论】:

          • 我喜欢这种方法,但是我缺乏一个很好的分数来捕捉最常见的差异。正如我所说,我已经使用了总长度的差异,并且我开始另外使用第一个辅音(因为差异在很大程度上源于元音的替代拼写)。但这有点过于严格了。你有更多建议吗?
          • 可能有两个分数:一个在辅音上,一个在元音上(与前面的原理相同),并在至少两个匹配时进行比较。也许,只需对您语言中最常用的字母执行此操作(参见维基百科中的字母频率)
          • 您可以在聚类阶段为每个字母添加一个特征,计算名字和名字中每个字母的数量。
          • 可能是这样的:parents_name &lt;- c("peter pan + marta steward", "pieter pan + marta steward", "armin dolgner + jane johanna dough", "jack jackson + sombody else") alphagrep &lt;- function (x) { res &lt;- NULL for (i in letters) {res &lt;- c(res, grepl(i, x))} res } sum(alphagrep(parents_name[1]) + alphagrep(parents_name[2]) == 1) sum(alphagrep(parents_name[1]) + alphagrep(parents_name[3]) == 1) sum(alphagrep(parents_name[1]) + alphagrep(parents_name[4]) == 1) 并且当总和小于 1 或 2 时进行比较或...根据您的需要..
          猜你喜欢
          • 2021-11-17
          • 1970-01-01
          • 1970-01-01
          • 2021-11-08
          • 2013-06-06
          • 2012-01-27
          • 2017-02-25
          • 1970-01-01
          • 2021-07-19
          相关资源
          最近更新 更多