基于多列的直接和间接相似性快速分组变量的方法

13

我有一个相对较大的数据集(1,750,000行,5列),其中包含带有唯一ID值(第一列)的记录,由四个标准(另外四列)描述。一个小例子如下:

# example
library(data.table)
dt <- data.table(id=c("a1","b3","c7","d5","e3","f4","g2","h1","i9","j6"), 
                 s1=c("a","b","c","l","l","v","v","v",NA,NA), 
                 s2=c("d","d","e","k","k","o","o","o",NA,NA),
                 s3=c("f","g","f","n","n","s","r","u","w","z"),
                 s4=c("h","i","j","m","m","t","t","t",NA,NA))

它看起来像这样:

   id   s1   s2 s3   s4
 1: a1    a    d  f    h
 2: b3    b    d  g    i
 3: c7    c    e  f    j
 4: d5    l    k  n    m
 5: e3    l    k  n    m
 6: f4    v    o  s    t
 7: g2    v    o  r    t
 8: h1    v    o  u    t
 9: i9 <NA> <NA>  w <NA>
10: j6 <NA> <NA>  z <NA>

我的最终目标是找到所有描述列中具有相同字符的记录(忽略NAs),并将它们分组为一个新的ID,以便我可以轻松地识别重复的记录。这些ID通过连接每行的ID构建而成。
由于我可以直接和间接地找到具有重复描述的记录,因此事情变得更加混乱。因此,我目前正在分两步进行此操作。
第一步 - 基于直接重复构建重复的ID
# grouping ids with duplicated info in any of the columns
#sorry, I could not find search for duplicates using multiple columns simultaneously...
dt[!is.na(dt$s1),ids1:= paste(id,collapse="|"), by = list(s1)]
dt[!is.na(dt$s1),ids2:= paste(id,collapse="|"), by = list(s2)]
dt[!is.na(dt$s1),ids3:= paste(id,collapse="|"), by = list(s3)]
dt[!is.na(dt$s1),ids4:= paste(id,collapse="|"), by = list(s4)]

# getting a unique duplicated ID for each row
dt$new.id <- apply(dt[,.(ids1,ids2,ids3,ids4)], 1, paste, collapse="|")
dt$new.id <- apply(dt[,"new.id",drop=FALSE], 1, function(x) paste(unique(strsplit(x,"\\|")[[1]]),collapse="|"))

这个操作会产生以下结果,其中唯一的重复ID被定义为“new.id”:
   id   s1   s2 s3   s4     ids1     ids2  ids3     ids4   new.id
 1: a1    a    d  f    h       a1    a1|b3 a1|c7       a1 a1|b3|c7
 2: b3    b    d  g    i       b3    a1|b3    b3       b3    b3|a1
 3: c7    c    e  f    j       c7       c7 a1|c7       c7    c7|a1
 4: d5    l    k  n    m    d5|e3    d5|e3 d5|e3    d5|e3    d5|e3
 5: e3    l    k  n    m    d5|e3    d5|e3 d5|e3    d5|e3    d5|e3
 6: f4    v    o  s    t f4|g2|h1 f4|g2|h1    f4 f4|g2|h1 f4|g2|h1
 7: g2    v    o  r    t f4|g2|h1 f4|g2|h1    g2 f4|g2|h1 f4|g2|h1
 8: h1    v    o  u    t f4|g2|h1 f4|g2|h1    h1 f4|g2|h1 f4|g2|h1
 9: i9 <NA> <NA>  w <NA>     <NA>     <NA>  <NA>     <NA>       NA
10: j6 <NA> <NA>  z <NA>     <NA>     <NA>  <NA>     <NA>       NA

请注意,记录“b3”和“c7”通过“a1”间接重复(所有其他示例都是直接重复,应保持不变)。这就是为什么我们需要下一步的原因。
第二步 - 根据间接重复更新重复的ID。
#filtering the relevant columns for the indirect search
dt = dt[,.(id,new.id)]

#creating the patterns to be used by grepl() for the look-up for each row
dt[,patt:= .(paste(paste("^",id,"\\||",sep=""),paste("\\|",id,"\\||",sep=""),paste("\\|",id,"$",sep=""),collapse = "" ,sep="")), by = list(id)]

#Transforming the ID vector into factor and setting it as a 'key' to the data.table (speed up the processing)
dt$new.id = as.factor(dt$new.id)
setkeyv(dt, c("new.id"))

#Performing the loop using sapply
library(stringr)
for(i in 1:nrow(dt)) {
  pat = dt$patt[i] # retrieving the research pattern
  tmp = dt[new.id %like% pat] # searching the pattern using grepl()
  if(dim(tmp)[1]>1) {
    x = which.max(str_count(tmp$new.id, "\\|"))
    dt$new.id[i] = as.character(tmp$new.id[x])
  }
}

#filtering the final columns 
dt = dt[,.(id,new.id)]

最终表格如下:
   id   new.id
 1: a1 a1|b3|c7
 2: b3 a1|b3|c7
 3: c7 a1|b3|c7
 4: d5    d5|e3
 5: e3    d5|e3
 6: f4 f4|g2|h1
 7: g2 f4|g2|h1
 8: h1 f4|g2|h1
 9: i9       NA
10: j6       NA

请注意,现在前三条记录(“a1”,“b3”,“c7”)被归为更广泛的重复ID下,其中包含直接和间接记录。
一切运行得很好,但我的代码非常慢。它花了两天时间运行半个数据集(〜800,0000)。我可以将循环并行化到不同的核心中,但仍需要几个小时。我几乎可以确定我可以更好地使用data.table功能,也许在循环中使用 'set' 。今天我花了几个小时尝试使用data.table实现相同的代码,但我对其语法还不熟悉,我真的很难。有什么建议可以优化这段代码吗?
注意:代码中最慢的部分是循环,循环内最低效的步骤是数据表内模式的grepl()。似乎设置数据表的'key'可以加速此过程,但在我的情况下未改变grepl()所需时间。

你有考虑过向量化操作吗?由于只有26个字母,你可以为每个字母/条件列创建一个变量。然后,你可以为每个观测值创建一个ID,例如在s1中具有a的观测值。然而,我不清楚这些新的ID如何有帮助。 - spazznolo
感谢您的评论@spazznolo。我正在使用此代码在数百个植物标本馆中搜索重复的植物标本。在实际数据中,ID和描述实际上是更大和可变的字符,并且有很多缺失的描述(这就是为什么我正在寻找间接重复项)。我确实尝试过使用'sapply'来向量化循环,但由于数据的大小,它仍然需要近2天的计算时间。再次感谢! - R. Lima
这些描述是否标准化?您是否有正在评估的样本清单? - spazznolo
再次感谢@spazznolo!在真实数据中,我的ID看起来像:“alcb_237”或“p_120356”。描述列是包含每个记录的连接描述的向量,它们看起来像:“Melastomataceae_jesus_34_sao sebastiao passe”或“Fabaceae_costa_1022_juazeiro”。因此,我不会说它们是标准化的。此外,某些记录的描述列中存在NA,而对于其他记录,则完整。 - R. Lima
只是为了正确理解,描述出现在哪一列是否重要?例如,如果字母“k”出现在s2列中,其含义是否与出现在s4列中相同。例如,如果第1行(id ==“a1”)中s4列中的“h”变成了“k”,预期结果将是c(rep('a1|b3|c7|d5|e3', 5), rep('f4|g2|h1', 3))吗? - Uwe
描述列中的字母只是一个例子。对于“o”的情况可能不太好,因为列之间的描述很可能不相同。而且,不,将“h”改为“k”也不会改变结果,因为查找重复项是按列进行的(在真实数据中,列之间重复的描述应该很少且随机)。 - R. Lima
2个回答

12

您可以将其视为网络问题。我在这里使用来自igraph软件包的函数。基本步骤:

  1. 将数据融合为长格式。

  2. 使用graph_from_data_frame创建一个图,其中“id”和“value”列被视为边缘列表。

  3. 使用components获取图的连通组件,即通过它们的标准直接或间接连接的“id”是哪些。

  4. 选择membership元素以获取“每个顶点所属的群集ID”。

  5. 将成员身份加入原始数据。

  6. 按簇成员身份分组连接'id'。


library(igraph)

# melt data to long format, remove NA values
d <- melt(dt, id.vars = "id", na.rm = TRUE)

# convert to graph
g <- graph_from_data_frame(d[ , .(id, value)])

# get components and their named membership id 
mem <- components(g)$membership

# add membership id to original data
dt[.(names(mem)), on = .(id), mem := mem] 

# for groups of length one, set 'mem' to NA
dt[dt[, .I[.N == 1], by = mem]$V1, mem := NA]

如果需要,可以通过'mem'列连接'id'(对于非NA的'mem')(在我看来这只会使进一步的数据操作更加困难 ;))。不管怎样,我们开始吧:

dt[!is.na(mem), id2 := paste(id, collapse = "|"), by = mem]

#     id   s1   s2 s3   s4  mem      id2
#  1: a1    a    d  f    h    1 a1|b3|c7
#  2: b3    b    d  g    i    1 a1|b3|c7
#  3: c7    c    e  f    j    1 a1|b3|c7
#  4: d5    l    k  l    m    2    d5|e3
#  5: e3    l    k  l    m    2    d5|e3
#  6: f4    o    o  s    o    3 f4|g2|h1
#  7: g2    o    o  r    o    3 f4|g2|h1
#  8: h1    o    o  u    o    3 f4|g2|h1
#  9: i9 <NA> <NA>  w <NA>   NA     <NA>
# 10: j6 <NA> <NA>  z <NA>   NA     <NA>

这是一个小例子中图形的基本绘制,仅用于说明连接组件:


plot(g, edge.arrow.size = 0.5, edge.arrow.width = 0.8, vertex.label.cex = 2, edge.curved = FALSE)

enter image description here


亲爱的@Henrik,感谢您的回答。但是与之前的答案类似,我省略了数据集中sp1:4有很多NAs的情况,这些不应该被视为相等。我已经编辑了我的问题,包括带有NAs的示例。我正在尝试在这里适应您的想法。如果我成功了,我会告诉您的。再次感谢。 - R. Lima
你认为一个简单的 'na.omit(d,cols = "value")' 是一个快速解决方案吗? - R. Lima
哦,我喜欢这个,应该比重复连接更快。 - Alexis
4
无以言表的感谢,@Henrik和Alexis。我刚刚用我的整个数据集(1,758,253行)测试了两个解决方案。两者都非常完美,但是Henrik的解决方案大约只需要30秒,而Alexis的解决方案则需要大约3分钟!从两天到不到3分钟的计算时间真是魔法!Henrik感谢你提供额外的连接代码,因为它们将用于集合间的存储和检索目的。这最终是代码中最慢的部分,负责总时间的约20秒,但是无论如何速度都非常快。再次感谢! - R. Lima
2
感谢@R.Lima发布了一个很好的第一个问题,提供了玩具数据和您已经尝试过的代码。干杯 - Henrik

6
我认为这种递归方法可以实现你想要的功能。 基本上,它对每一列执行自连接,一次一列, 如果匹配了多行(即不是正在考虑的行), 它会保存所有匹配中的唯一 id。 通过利用 secondary indices,避免使用具有 NA 的行。 关键在于我们进行两次递归, 一次使用 id,另一次使用新创建的 new_id
dt[, new_id := .(list(character()))]

get_ids <- function(matched_ids, new_id) {
  if (length(matched_ids) > 1L) {
    list(unique(
      c(new_id[[1L]], unlist(matched_ids))
    ))
  } else {
    new_id
  }
}

find_recursively <- function(dt, cols, pass) {
  if (length(cols) == 0L) return(invisible())

  current <- cols[1L]
  next_cols <- cols[-1L]

  next_dt <- switch(
    pass,

    first = dt[!list(NA_character_),
               new_id := dt[.SD, .(get_ids(x.id, i.new_id)), on = current, by = .EACHI]$V1,
               on = current],

    second = dt[!list(NA_character_),
                new_id := dt[.SD, .(get_ids(x.new_id, i.new_id)), on = current, by = .EACHI]$V1,
                on = current]
  )

  find_recursively(next_dt, next_cols, pass)
}

find_recursively(dt, paste0("s", 1:4), "first")
find_recursively(dt, paste0("s", 1:4), "second")

dt[, new_id := sapply(new_id, function(nid) {
  ids <- unlist(nid)
  if (length(ids) == 0L) {
    NA_character_
  } else {
    paste(ids, collapse = "|")
  }
})]

print(dt)
    id   s1   s2 s3   s4   new_id
 1: a1    a    d  f    h a1|b3|c7
 2: b3    b    d  g    i a1|b3|c7
 3: c7    c    e  f    j a1|c7|b3
 4: d5    l    k  l    m    d5|e3
 5: e3    l    k  l    m    d5|e3
 6: f4    o    o  s    o f4|g2|h1
 7: g2    o    o  r    o f4|g2|h1
 8: h1    o    o  u    o f4|g2|h1
 9: i9 <NA> <NA>  w <NA>     <NA>
10: j6 <NA> <NA>  z <NA>     <NA>

连接使用这个成语


我有一种感觉,这可以得到改进,不确定它是否足够快。我会考虑一下。 - Alexis
非常感谢 @Alexis。我对原始数据的样本进行了测试,以查看其速度有多快。 - R. Lima
亲爱的@Alexis,我正准备测试您提供的代码的性能,但后来意识到我漏掉了一件非常重要的事情:数据集在sp1:4中有很多NA值,这些值不应视为相等。我已经编辑了我的问题,包括带有NA的示例,但我无法适应您的代码执行相同的操作。您有什么想法,在您的代码中哪里应该声明类似于!is.na(sp1:4)的内容?再次感谢。 - R. Lima
1
@R.Lima 我已经修改了代码,我之前用法错误。我仍然不确定这是否是最好的方法,但肯定要试一下并告诉我。 - Alexis
@R.Lima,出于好奇,为什么你不能使用Henrik的解决方案? - Alexis
初学者的错误。我以为我可以接受两个答案,因为它们都很好地解决了问题。然后我重新接受他的答案,因为它更快。 - R. Lima

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接