删除 R 中的模糊重复项

Removing Fuzzy Duplicates in R

提问人:stats_noob 提问时间:11/9/2022 最后编辑:stats_noob 更新时间:11/17/2022 访问量:948

问:

我有一个数据集,在 R 中看起来像这样:

address = c("882 4N Road River NY, NY 12345", "882 - River Road NY, ZIP 12345", "123 Fake Road Boston Drive Boston", "123 Fake - Rd Boston 56789")
            
 name = c("ABC Center Building", "Cent. Bldg ABC", "BD Home 25 New", "Boarding Direct 25")
            
my_data = data.frame(address, name)

                            address                name
1    882 4N Road River NY, NY 12345 ABC Center Building
2    882 - River Road NY, ZIP 12345      Cent. Bldg ABC
3 123 Fake Road Boston Drive Boston      BD Home 25 New
4        123 Fake - Rd Boston 56789  Boarding Direct 25

查看此数据,很明显前两行是相同的,后两行是相同的。但是,如果您尝试直接删除重复项,则标准函数(例如“”)将声明此数据集中没有重复项,因为所有行都具有一些唯一的元素。distinct()

我一直在尝试研究 R 中的不同方法,这些方法能够根据“模糊条件”对行进行重复数据删除。

根据这里提供的答案(查找近似重复记录的技术),我遇到了这种称为“记录链接”的方法。我在这里(https://cran.r-project.org/web/packages/RecordLinkage/vignettes/WeightBased.pdf)遇到了这个特定的教程,它可能能够执行类似的任务,但我不确定这是否适用于我正在处理的问题。

  • 有人可以帮我确认这个记录链接教程是否实际上与我正在处理的问题相关 - 如果是这样,有人可以告诉我如何使用它吗?

  • 例如,我想根据名称和地址删除重复项 - 并且只剩下两行(即 row1/row2 中的一行和 row3/row4 中的一行 - 无论选择哪一个都无关紧要)。

  • 再举一个例子 - 假设我想尝试这个,并且只根据“地址”列进行重复数据删除:这也可行吗?

有人可以告诉我这是如何工作的吗?

谢谢!

注意:我听说过一些关于将 SQL JOINS 与 FUZZY JOINS 一起使用的选项(例如 https://cran.r-project.org/web/packages/fuzzyjoin/readme/README.html) - 但我不确定此选项是否也适用。

SQL R 复制 数据操作 模糊逻辑

评论

5赞 thelatemail 11/9/2022
是的,您处于创纪录的链接领域。特别是对于地址,您可能需要研究标记化(即将字符串解析为批号、街道名称、邮政编码等),甚至对每行进行地理编码,然后使用标准化形式。这根本不是一项简单的任务,也没有明确的“答案”,而不仅仅是重写一本教科书。
1赞 thelatemail 11/9/2022
看看这里的幻灯片 - rpubs.com/ahmademad/RecordLinkage - 一个工作的例子。您在数据方面遇到的问题是,您需要清理单独的字段才能尝试进行链接。这意味着这样的事情:stackoverflow.com/questions/68029796/parse-address-strings-in-r 需要先完成。
1赞 Andrew Gustar 11/11/2022
你可以看看这个包,它实现了 OpenRefine openrefine.org 中使用的一些模糊匹配算法。根据我的经验,这是一个棘手的问题,总是需要大量的手动检查和纠正,但 OpenRefine 通常是一个很好的起点。refineR
1赞 Wimpel 11/14/2022
如果您的地址数据不是完全随机的,您可以尝试使用 (non-CRAN) -package 设置工作流。slu-opengis.github.io/postmastr/articles/postmastr.htmlpostmastr
1赞 Captain Hat 11/14/2022
另一种解决方案可能是使用API,例如Google Maps提供的API-您可以对结果进行地理编码,有效地利用其地址解析算法,而不是创建自己的算法。

答:

3赞 zephryl 11/12/2022 #1

stringdist::stringdist()对于查找近似重复项很有用,至少在相对简单的情况下是这样。

使用您的示例数据,我们可以执行笛卡尔自联接来获取所有行的组合;用于计算 和 的所有行对的距离*;并首先使用最相似的行对进行排列:stringdist::stringdist()addressname

library(dplyr)
library(tidyr)
library(stringdist)

my_data_dists <- my_data %>% 
  mutate(row = row_number()) %>% 
  full_join(., ., by = character()) %>% 
  filter(row.x < row.y) %>% 
  mutate(
    address.dist = stringdist(address.x, address.y),
    name.dist = stringdist(name.x, name.y)
  ) %>% 
  arrange(scale(address.dist) + scale(name.dist)) %>% 
  relocate(
    row.x, row.y,
    address.dist, name.dist,
    address.x, address.y, 
    name.x, name.y
  )
  row.x row.y address.dist name.dist                         address.x                         address.y              name.x             name.y
1     1     2           13        13    882 4N Road River NY, NY 12345    882 - River Road NY, ZIP 12345 ABC Center Building     Cent. Bldg ABC
2     3     4           15        16 123 Fake Road Boston Drive Boston        123 Fake - Rd Boston 56789      BD Home 25 New Boarding Direct 25
3     2     3           25        13    882 - River Road NY, ZIP 12345 123 Fake Road Boston Drive Boston      Cent. Bldg ABC     BD Home 25 New
4     1     3           25        15    882 4N Road River NY, NY 12345 123 Fake Road Boston Drive Boston ABC Center Building     BD Home 25 New
5     2     4           23        17    882 - River Road NY, ZIP 12345        123 Fake - Rd Boston 56789      Cent. Bldg ABC Boarding Direct 25
6     1     4           25        18    882 4N Road River NY, NY 12345        123 Fake - Rd Boston 56789 ABC Center Building Boarding Direct 25

从这里,您可以手动清除重复项,或查看结果以选择距离阈值以将行视为“重复项”。如果我们采用后一种方法:它看起来可能不是一个可靠的指标(例如,最低值之一是误报),但低于 20 的分数似乎是可靠的。然后,您可以应用此功能来筛选原始数据。name.distaddress.dist

dupes <- my_data_dists$row.y[my_data_dists$address.dist < 20]

my_data[-dupes,]
                            address                name
1    882 4N Road River NY, NY 12345 ABC Center Building
3 123 Fake Road Boston Drive Boston      BD Home 25 New

对于更复杂的情况(例如,更多列、非常大的数据集),最好使用 RecordLinkage 或注释中的其他一些建议。但是我发现 stringdist 对于只涉及几列的情况非常灵活且有用。

编辑:另一个接口由 或 提供,它返回一个对象或一个或两个向量的元素之间的距离:stringdist::stringdistmatrix()utils::adist()distmatrix

stringdistmatrix(my_data$name)
#    1  2  3
# 2 13      
# 3 15 13   
# 4 18 17 16

adist(my_data$name)
#      [,1] [,2] [,3] [,4]
# [1,]    0   13   15   18
# [2,]   13    0   13   17
# [3,]   15   13    0   16
# [4,]   18   17   16    0

编辑2:我添加了更多信息,以回答 OP 的问题。


* StringDist 函数默认使用最佳字符串对齐方式,但可以在方法参数中指定其他指标

评论

1赞 zephryl 11/12/2022
您将使用相同的代码,只需从调用中删除,并使用适当的阈值而不是获取索引...但我有一种感觉,我误解了你的问题?address.distmutate()my_data_dists$name.distmy_data_dists$address.distdupes
1赞 zephryl 11/12/2022
我已经编辑了我的答案,以提及返回距离矩阵的替代函数,这可能对单向量情况有用。我还会浏览 stringdist 中可用的其他函数
2赞 zephryl 11/13/2022
我的回答有点啰嗦,所以我把它放在一个要点中。至于偏好,我更喜欢 stringdist 包,因为它具有一系列用于近似匹配的有用函数,而我认为基 R 仅限于 和 。stringdist 还实现了 ~10 个不同的距离指标,而 base 只实现了 Levenshtein。base::agrep()utils::adist()
1赞 stats_noob 11/13/2022
有什么方法可以解决这个错误吗?这个问题更适合记录链接吗?
1赞 MrSmithGoesToWashington 11/14/2022
可能看看这个
6赞 JBGruber 11/16/2022 #2

对于这样的任务,我喜欢使用分而治之的策略,因为在比较更多字符串或更长的字符串时,您很快就会遇到内存问题。

library(tidyverse)
library(quanteda)
library(quanteda.textstats)
library(stringdist)

第 1 阶段:代币相似性

我添加了一个 ID 列,并将名称和地址合并为全文进行比较。

my_data2 <-  my_data|>
  mutate(ID = factor(row_number()),
         fulltext = paste(name, address))

在相似性的方法中,在比较两个字符串中哪些标记相同之前,将字符串划分为单词/标记。与字符串距离相比,这是非常有效的:quanteda

duplicates <- my_data2 |> 
  # a bunch of wrangling to create the quanteda dfm object
  corpus(docid_field = "ID",
         text_field = "fulltext") |> 
  tokens() |> 
  dfm() |> 
  # calculate similarity using cosine (other methods are available)
  textstat_simil(method = "cosine") |> 
  as_tibble() |>
  # attaching the original documents back to the output 
  left_join(my_data2, by = c("document1" = "ID")) |> 
  left_join(my_data2, by = c("document2" = "ID"), suffix = c("", "_comparison"))

duplicates |> 
  select(cosine, 
         address, address_comparison, 
         name, name_comparison)
#> # A tibble: 5 × 5
#>   cosine address                           address_comparison      name  name_…¹
#>    <dbl> <chr>                             <chr>                   <chr> <chr>  
#> 1 0.641  882 4N Road River NY, NY 12345    882 - River Road NY, Z… ABC … Cent. …
#> 2 0.0801 882 4N Road River NY, NY 12345    123 Fake Road Boston D… ABC … BD Hom…
#> 3 0.0833 882 - River Road NY, ZIP 12345    123 Fake Road Boston D… Cent… BD Hom…
#> 4 0.0962 882 - River Road NY, ZIP 12345    123 Fake - Rd Boston 5… Cent… Boardi…
#> 5 0.481  123 Fake Road Boston Drive Boston 123 Fake - Rd Boston 5… BD H… Boardi…
#> # … with abbreviated variable name ¹​name_comparison

如您所见,第一个和第二个以及第三个和第四个条目具有相当高的相似性,分别为 0.641 和 0.481。在大多数情况下,这种比较已经足以识别重复项。但是,它完全忽略了词序。典型的例子是,“狗咬人”和“人咬狗”具有100%的象征相似性,但含义却完全不同。查看您的数据集,以确定令牌的顺序是否起作用。如果您认为是这样,请继续阅读。

第 2 阶段:字符串相似性

在 stringdist 中实现的字符串相似性是距离的规范化版本。看距离,你比较的文本的长度没有作用。但是,两个字母不同的两个 4 个字母的字符串非常不同,而两个 100 个字母的字符串则不然。您的示例看起来可能不是一个大问题,但总的来说,出于这个原因,我更喜欢相似性。

然而,字符串相似性和距离的问题在于,它们的计算成本非常高。即使是几篇 100 篇短文本也会很快占用您的整个记忆。因此,您可以做的是过滤上面的结果,并仅计算已经看起来像是重复的候选者的字符串相似性:

duplicates_stringsim <- duplicates |> 
  filter(cosine > 0.4) |> 
  mutate(stringsim = stringsim(fulltext, fulltext_comparison, method = "lv"))

duplicates_stringsim |> 
  select(cosine, stringsim,
         address, address_comparison, 
         name, name_comparison)
#> # A tibble: 2 × 6
#>   cosine stringsim address                           address_com…¹ name  name_…²
#>    <dbl>     <dbl> <chr>                             <chr>         <chr> <chr>  
#> 1  0.641     0.48  882 4N Road River NY, NY 12345    882 - River … ABC … Cent. …
#> 2  0.481     0.354 123 Fake Road Boston Drive Boston 123 Fake - R… BD H… Boardi…
#> # … with abbreviated variable names ¹​address_comparison, ²​name_comparison

为了进行比较,我们已经消除的其他三个比较的 stringsim 是 0.2、0.208 和 0.133。尽管略小,但字符串相似性证实了第 1 阶段的结果。

现在最后一步是从原始 data.frame 中删除重复项。为此,我使用另一个过滤器,从duplicates_stringsim对象中提取 ID,然后从数据中删除这些重复项。

dup_ids <- duplicates_stringsim |> 
  filter(stringsim > 0.3) |> 
  pull(document2)


my_data2 |> 
  filter(!ID %in% dup_ids)
#>                             address                name ID
#> 1    882 4N Road River NY, NY 12345 ABC Center Building  1
#> 2 123 Fake Road Boston Drive Boston      BD Home 25 New  3
#>                                             fulltext
#> 1 ABC Center Building 882 4N Road River NY, NY 12345
#> 2   BD Home 25 New 123 Fake Road Boston Drive Boston

创建于 2022-11-16 使用 reprex v2.0.2

请注意,我根据您对示例的要求选择了截止值。您必须针对您的数据集以及可能的所有新项目进行微调。

评论

0赞 stats_noob 11/16/2022
@JBGruber:非常感谢您的回答!只是一个问题 - 假设我只有 1 列数据......或者假设我有 4 列数据......从理论上讲,您提供的相同代码是否仍然有效?非常感谢!
1赞 JBGruber 11/16/2022
是的,完全。看看这一行.它将两个相关列合二为一。您也可以在此处仅使用一列或 4 列或 20 列。fulltext = paste(name, address)
0赞 stats_noob 11/17/2022
我在这里正在处理一个相关问题,但这次我尝试使用记录链接方法:stackoverflow.com/questions/74427483/...。你对此有什么想法吗?非常感谢!
0赞 JBGruber 11/18/2022
看起来你已经在那边得到了一个很好的答案。
1赞 Andre Wildberg 11/17/2022 #3

当同时使用 address 和 name 以及 only address 时,使用 and 设置 (60%) 会得到很好的结果。如果用于较大的数据集,可能会略有不同。agrepmax.distance = list(all = 0.6)

agrep
近似字符串匹配(模糊匹配)

  • max.distance:比赛允许的最大距离。
    'all': 所有转换的最大数量/分数 (插入、删除和替换)

过滤唯一条目,保留第一个条目(可以调整为使用最长的条目等)。

my_data[unique(sapply(paste(my_data$address, my_data$name), function(x)
  agrep(x, paste(my_data$address, my_data$name), 
    max.distance = list(all = 0.6))[1])),]
                            address                name
1    882 4N Road River NY, NY 12345 ABC Center Building
3 123 Fake Road Boston Drive Boston      BD Home 25 New

仅使用地址

my_data[unique(sapply(my_data$address, function(x) 
  agrep(x, my_data$address, max.distance = list(all = 0.6))[1])),]
                            address                name
1    882 4N Road River NY, NY 12345 ABC Center Building
3 123 Fake Road Boston Drive Boston      BD Home 25 New