Efficient filtering through multiple columns by group

Viewed 738

Assume a dataset containing multiple rows per ID and multiple columns containing some codes stored as strings:

df <- data.frame(id = rep(1:3, each = 2),
                 var1 = c("X1", "Y1", "Y2", "Y3", "Z1", "Z2"),
                 var2 = c("Y1", "X2", "Y2", "Y3", "Z1", "Z2"),
                 var3 = c("Y1", "Y2", "X1", "Y3", "Z1", "Z2"),
                 stringsAsFactors = FALSE)

  id var1 var2 var3
1  1   X1   Y1   Y1
2  1   Y1   X2   Y2
3  2   Y2   Y2   X1
4  2   Y3   Y3   Y3
5  3   Z1   Z1   Z1
6  3   Z2   Z2   Z2

Now, assume that I want to filter out all IDs that have a specific code (here X) in any of the relevant columns. With dplyr and purrr, I could do:

df %>%
 group_by(id) %>%
 filter(all(reduce(.x = across(var1:var3, ~ !grepl("^X", .)), .f = `&`)))

     id var1  var2  var3 
  <int> <chr> <chr> <chr>
1     3 Z1    Z1    Z1   
2     3 Z2    Z2    Z2 

It works fine, it's compact and it's easy to understand, however, it's fairly inefficient with big datasets (millions of IDs and tens of millions of observations). I would welcome any ideas for computationally more efficient code, using any library.

11 Answers

Some Possible Points for Speed

  • Try NOT using something like group by, i.e., group_by in dplyr or by = in data.table, since that will slow down your overall performance
  • If you have fixed objective pattern, e.g., starting with X, then substr might be more efficient than grepl with pattern ^X

Some Base R Approaches

It seems we can speed up further based on the @Waldi's fastest approach through the following one

TIC1 <- function() {
    subset(df, ave(rowSums(substr(as.matrix(df[, -1]), 1, 1) == "X") == 0, id, FUN = all))
}

or

TIC2 <- function() {
    subset(df, !id %in% id[rowSums(substr(as.matrix(df[, -1]), 1, 1) == "X") > 0])
}

or

TIC3 <- function() {
    subset(df, !id %in% id[do.call(pmax, lapply(df[-1], function(v) substr(v, 1, 1) == "X")) > 0])
}

Benchmarking

compared to answers from @Waldi and @EnricoSchumann:

microbenchmark(
    TIC1(),
    TIC2(),
    TIC3(),
    fun1(),
    fun2(),
    waldi_speed(),
    unit = "relative"
)

Unit: relative
          expr       min        lq      mean    median        uq       max
        TIC1()  3.385215  3.451424  3.488670  3.569668  3.684895  3.618991
        TIC2()  1.062116  1.084568  1.074789  1.090400  1.114443  1.027673
        TIC3()  1.077660  2.208734  2.185960  2.214180  2.293366  2.141994
        fun1()  1.166342  1.155096  1.169574  1.153223  1.207932  1.405530
        fun2()  1.000000  1.000000  1.000000  1.000000  1.000000  1.000000
 waldi_speed() 26.218953 26.560429 26.373054 26.952997 27.396017 26.333575
 neval
   100
   100
   100
   100
   100
   100

given

n <- 5e4
df <- data.frame(
    id = rep(1:(n / 2), each = 2, length.out = n),
    var1 = mapply(paste0, LETTERS[23 + sample(1:3, n, replace = T)], sample(1:3, n, replace = T)),
    var2 = mapply(paste0, LETTERS[23 + sample(1:3, n, replace = T)], sample(1:3, n, replace = T)),
    var3 = mapply(paste0, LETTERS[23 + sample(1:3, n, replace = T)], sample(1:3, n, replace = T)),
    stringsAsFactors = FALSE
)

TIC1 <- function() {
    subset(df, ave(rowSums(substr(as.matrix(df[, -1]), 1, 1) == "X") == 0, id, FUN = all))
}

TIC2 <- function() {
    subset(df, !id %in% id[rowSums(substr(as.matrix(df[, -1]), 1, 1) == "X") > 0])
}

TIC3 <- function() {
    subset(df, !id %in% id[do.call(pmax, lapply(df[-1], function(v) substr(v, 1, 1) == "X")) > 0])
}


waldi_speed <- function() {
    setDT(df)
    df[df[, .(keep = .I[!any(grepl("X", .SD))]), by = id, .SDcols = patterns("var")]$keep]
}


repeated_or <- function(...) {
    L <- list(...)
    ans <- L[[1L]]
    if (...length() > 1L) {
          for (i in seq.int(2L, ...length())) {
                ans <- ans | L[[i]]
            }
      }
    ans
}

fun1 <- function() {
    ## using a pattern
    m <- lapply(df[, -1], grepl, pattern = "^X", perl = TRUE)
    df[!df$id %in% df$id[do.call(repeated_or, m)], ]
}

fun2 <- function() {
    ## using a fixed string
    m <- lapply(df[, -1], function(x) substr(x, 1, 1) == "X")
    df[!df$id %in% df$id[do.call(repeated_or, m)], ]
}

Here is an alternative tidyverse approach.

my_fun <- function(.data) {
  .data %>% 
    group_by(id) %>% 
    filter(!grepl("X", paste(var1, var2, var3, collapse = ""))) %>% 
    ungroup()
}

my_fun(df)

# # A tibble: 2 x 4
#      id var1  var2  var3 
#   <int> <chr> <chr> <chr>
# 1     3 Z1    Z1    Z1   
# 2     3 Z2    Z2    Z2   

df_fun <- function(.data) {
  .data %>%
    group_by(id) %>%
    filter(all(reduce(.x = across(var1:var3, ~ !grepl("^X", .)), .f = `&`))) %>% 
    ungroup()
}

performance <- bench::mark(
  my_fun(df),
  df_fun(df)
)

performance %>% select(1:4)

# # A tibble: 2 x 4
#   expression       min   median `itr/sec`
#   <bch:expr>  <bch:tm> <bch:tm>     <dbl>
# 1 my_fun(df)    2.6ms    2.7ms      364.
# 2 df_fun(df)    6.01ms   6.39ms      152.

Two other data.table solutions:

library(data.table)
setDT(df)
df[,.SD[!any(grepl("X", .SD))],by=id,.SDcols=patterns('var')]

   id var1 var2 var3
1:  3   Z1   Z1   Z1
2:  3   Z2   Z2   Z2

Which can be improved for speed at the cost of a bit less readability:

df[df[, .(keep=.I[!any(grepl("X", .SD))]), by=id,.SDcols=patterns('var')]$keep]

Benchmarking:

n <- 1e4
df <- data.frame(id = rep(1:(n/2), each = 2,length.out=n),
                 var1 = mapply(paste0,LETTERS[23+sample(1:3,n,replace=T)],sample(1:3,n,replace=T)),
                 var2 = mapply(paste0,LETTERS[23+sample(1:3,n,replace=T)],sample(1:3,n,replace=T)),
                 var3 = mapply(paste0,LETTERS[23+sample(1:3,n,replace=T)],sample(1:3,n,replace=T)),
                 stringsAsFactors = FALSE) 


Unit: milliseconds
          expr       min         lq        mean    median         uq       max neval
         ref() 2131.5304 2285.54535 2401.612346 2367.8145 2480.10490 3294.9647   100
  TeamTeaFan() 1760.1280 1918.29075 1986.489995 1967.7518 2029.02090 2858.8118   100
       ronak()  289.1461  306.06050  324.418149  314.4888  333.44100  468.1077   100
        anil()  230.5183  244.04175  259.687656  255.4336  267.69550  370.5758   100
       waldi()  226.5081  238.23055  256.824345  251.8372  267.23395  384.6071   100
 waldi_speed()   41.0354   45.12365   51.428189   48.6736   55.20530  155.4654   100
         zaw()   25.9210   28.96225   33.508240   31.2333   37.77565   49.5777   100
         TIC()    3.9299    4.51920    5.295555    4.8717    5.43565   14.7225   100

Another base R solution, using the code example that ThomasIsCoding has provided. First, define a helper function:

repeated_or <- function(...) {
    L <- list(...)
    ans <- L[[1L]]
    if (...length() > 1L)
        for (i in seq.int(2L, ...length()))
            ans <- ans | L[[i]]
    ans
}

It will take any number of logical vectors x1, x2, x3, ... and produce x1 | x2 | x3 ... and so on.

The actual work is done by the following function, in two variants. The function assumes that all columns but the first are to be searched.

fun1 <- function() {
    ## using a pattern
    m <- lapply(df[, -1], grepl, pattern = "^X", perl = TRUE)
    df[!df$id %in% df$id[do.call(repeated_or, m)], ]
}

fun2 <- function() {
    ## using a fixed string
    m <- lapply(df[, -1], function(x) substr(x, 1,1) == "X")
    df[!df$id %in% df$id[do.call(repeated_or, m)], ]
}

Now, using the code that ThomasIsCoding provided:

n <- 1e4
df <- data.frame(
    id = rep(1:(n / 2), each = 2, length.out = n),
    var1 = mapply(paste0, LETTERS[23 + sample(1:3, n, replace = T)], sample(1:3, n, replace = T)),
    var2 = mapply(paste0, LETTERS[23 + sample(1:3, n, replace = T)], sample(1:3, n, replace = T)),
    var3 = mapply(paste0, LETTERS[23 + sample(1:3, n, replace = T)], sample(1:3, n, replace = T)),
    stringsAsFactors = FALSE
)

library("microbenchmark")
microbenchmark(
    fun1(),
    fun2(),
    TIC1(),
    TIC2(),
    waldi_speed(),
    unit = "relative"
)
## Unit: relative
##           expr       min        lq      mean    median        uq       max neval
##         fun1()  1.180372  1.183109  1.205269  1.189091  1.187704  1.163667   100
##         fun2()  1.000000  1.000000  1.000000  1.000000  1.000000  1.000000   100
##         TIC1()  3.487775  3.462417  3.549228  3.491580  3.494310  2.857216   100
##         TIC2()  1.140145  1.131872  1.141466  1.146900  1.142863  1.078746   100
##  waldi_speed() 31.440025 30.845971 30.556054 30.798701 30.338251 26.213920   100
  • Specialized function: You might make many operations looking if the specific code is found. Using a specialized function for that kind might be faster than a general function. startsWith(x, "X") will be faster than grepl("^X", x).

  • Subsetting: In case the function looking for the specific code is slow (the operation is slower than subsetting), make this operation only for the remaining columns in rows where the code was not already found.

  • Hash Lookup: You need to compare all remaining ids, which had no direct hit, if any of the rows with the same id had a hit. So the lookup in the list, holding the ids which had a hit, should be fast. This lookup might be fast using a hash table like fastmatch::fmatch.

  • Storage Type: In case columns of a data.frame have all the same type, operations on that might be faster when it is stored in a matrix instead of a list.

  • Avoid rearrange data: Try to use the data as it is. Avoid operations like split or group which will rearrange the present data.


You can unlist df[-1] and test if it startsWith X, create a matrix with nrow of df and take the rowSums, in case it is >0 the id has a hit. I store those id in i. Optional the unique id's could be calcualted. Now test if the id is %in% i and take the oposit using !. A maybe faster alternative to %in% is %fin% from fastmatch.

i <- df$id[unlist(df[-1], FALSE, FALSE) |>
             startsWith("X") |>
             matrix(nrow(df)) |>
             rowSums() > 0]
#i <- unique(i)       #Optional
#i <- kit::funique(i) #Optional faster unique
df[!df$id %in% i,]
#  id var1 var2 var3
#5  3   Z1   Z1   Z1
#6  3   Z2   Z2   Z2

library(fastmatch)
df[!df$id %fin% i,]

Another way to come to the previously used i over lappyl and using | in Reduce or in case Reduce gets slow maybe use instead eval with str2lang and paste:

i <- lapply(df[,-1], startsWith, "X")
i <- df$id[Reduce(`|`, i)]
#i <- eval(str2lang(paste0("i[[", seq_along(i), "]]", collapse = "|"))) #Alternative to Reduce
df[!df$id %in% i,]

Also it's possible to test if it starts with X only in those cases which didn't have a hit already and use %in% only for those rows where there was no hit with X, which will make sense when subsetting is faster than testing if it starts with X and if subsetting faster than looking for a match.

i <- Reduce(function(x, y) `[<-`(x,!x,startsWith(y[!x], "X")),
       df[,-1], logical(nrow(df)))
i[!i] <- df$id[!i] %in% df$id[i]
df[!i,]

Benchmark based on @Waldi with method TIC2() from @thomasiscoding and fun2() from @enrico-schumann:

 getDf <- function(nr, nc) { #function to creat example dataset
    data.frame(id = sample(seq_len(nr/5), nr, TRUE),
      lapply(setNames(seq_len(nc), paste0("var", seq_len(nc))),
        function(i) paste0(sample(LETTERS, nr, TRUE), sample(0:9, nr, TRUE))))
}

library(fastmatch)
FGKi1 <- function() {
  df[!df$id %in% df$id[rowSums(matrix(startsWith(unlist(df[-1], FALSE, FALSE),
                                                 "X"), nrow(df))) > 0],]}
FGKi2 <- function() {
  df[!df$id %in% unique(df$id[rowSums(matrix(startsWith(unlist(df[-1],
                                 FALSE, FALSE), "X"), nrow(df))) > 0]),]}
FGKi3 <- function() {
  df[!df$id %fin% df$id[rowSums(matrix(startsWith(unlist(df[-1], FALSE, FALSE),
                                                  "X"), nrow(df))) > 0],]}
FGKi4 <- function() {
  df[!df$id %in% df$id[Reduce(`|`, lapply(df[, -1], startsWith, "X"))],]
}
FGKi5 <- function() {
  df[!df$id %fin% df$id[Reduce(`|`, lapply(df[, -1], startsWith, "X"))],]
}
FGKi6 <- function() {
  i <- Reduce(`|`, lapply(df[, -1], startsWith, "X"))
  i[!i] <- df$id[!i] %in% df$id[i]
  df[!i,]
}
FGKi7 <- function() {
  i <- lapply(df[, -1], startsWith, "X")
  i <- eval(str2lang(paste0("i[[", seq_along(i), "]]", collapse = "|")))
  df[!df$id %fin% df$id[i],]
}
repeated_or <- function(...) {
    L <- list(...)
    ans <- L[[1L]]
    if (...length() > 1L)
        for (i in seq.int(2L, ...length()))
            ans <- ans | L[[i]]
    ans
}
fun2 <- function() {
    ## using a fixed string
    m <- lapply(df[, -1], function(x) substr(x, 1,1) == "X")
    df[!df$id %in% df$id[do.call(repeated_or, m)], ]
}
TIC2 <- function() {
    subset(df, !id %in% id[rowSums(substr(as.matrix(df[, -1]), 1, 1) == "X") > 0])
}
set.seed(42)
df <- getDf(1e5, 3) #3 col wide Table
bench::mark(TIC2(), fun2(), FGKi1(), FGKi2(), FGKi3(), FGKi4(),
   FGKi5(), FGKi6(), FGKi7())
#  expression     min  median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time
#  <bch:expr> <bch:t> <bch:t>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm>
#1 TIC2()      24.7ms  24.9ms      40.2   15.07MB    112.      5    14      125ms
#2 fun2()      22.3ms  22.5ms      43.9   11.26MB     39.9    11    10      251ms
#3 FGKi1()     14.6ms    15ms      66.8   12.78MB     58.9    17    15      255ms
#4 FGKi2()     14.9ms  15.1ms      66.3   12.97MB     58.5    17    15      256ms
#5 FGKi3()     12.1ms  12.3ms      80.8   12.23MB     72.3    19    17      235ms
#6 FGKi4()     12.7ms  12.9ms      77.7    8.97MB     27.7    28    10      360ms
#7 FGKi5()     10.2ms  10.3ms      96.4    8.42MB     51.4    30    16      311ms
#8 FGKi6()     13.2ms  13.3ms      75.1   11.38MB     53.6    21    15      280ms
#9 FGKi7()     10.3ms  10.4ms      95.2    8.42MB     36.8    31    12      326ms

set.seed(42)
df <- getDf(1e4, 1e3) #1000 col wide Table
bench::mark(TIC2(), fun2(), FGKi1(), FGKi2(), FGKi3(), FGKi4(),
   FGKi5(), FGKi6(), FGKi7())
#  expression     min  median `itr/sec` mem_alloc `gc/sec` n_itr  n_gc total_time
#  <bch:expr> <bch:t> <bch:t>     <dbl> <bch:byt>    <dbl> <int> <dbl>   <bch:tm>
#1 TIC2()     430.4ms 434.4ms      2.30     230MB     3.45     2     3      869ms
#2 fun2()     374.6ms 405.6ms      2.47     191MB     6.16     2     5      811ms
#3 FGKi1()    110.8ms 117.7ms      7.87     191MB    13.8      4     7      509ms
#4 FGKi2()    108.9ms 111.1ms      8.32     191MB    11.7      5     7      601ms
#5 FGKi3()    107.8ms 107.8ms      9.25     191MB     9.25     5     5      541ms
#6 FGKi4()     52.5ms  54.6ms     16.6      115MB    14.7      9     8      543ms
#7 FGKi5()     52.5ms  54.7ms     18.3      115MB    18.3     10    10      547ms
#8 FGKi6()     52.8ms  55.2ms     18.1      115MB    16.3     10     9      553ms
#9 FGKi7()     53.7ms  56.6ms     17.6      115MB    17.6      9     9      510ms
#Warning message:
#Some expressions had a GC in every iteration; so filtering is disabled. 

Here's a data.table variation -

library(data.table)
cols <- grep('var', names(df))

setDT(df)

df[, .SD[all(!Reduce(`|`, lapply(.SD, grepl, pattern = '^X')))], id, .SDcols = cols]

#   id var1 var2 var3
#1:  3   Z1   Z1   Z1
#2:  3   Z2   Z2   Z2

You can simply use cur_data() by making it behave like a vector/matrix i.e. wrapping it with as.vector or more appropriately with as.matrix

library(tidyverse)

df %>%
  group_by(id) %>%
  filter(!any(str_detect(as.matrix(cur_data()), 'X')))

#> # A tibble: 2 x 4
#> # Groups:   id [1]
#>      id var1  var2  var3 
#>   <int> <chr> <chr> <chr>
#> 1     3 Z1    Z1    Z1   
#> 2     3 Z2    Z2    Z2

OR if you want to use it on selected columns only

df %>%
  group_by(id) %>%
  filter(!any(grepl('X', as.matrix(select(cur_data(), starts_with('var'))))))

Another option would be to use the new if_all (or if_any). To work with the problem above we need to further wrap it in all:

library(dplyr)

df %>% 
  group_by(id) %>% 
  filter(all(if_all(starts_with("var"),
                    ~ !grepl("^X", .x))))

#> # A tibble: 2 x 4
#> # Groups:   id [1]
#>      id var1  var2  var3 
#>   <int> <chr> <chr> <chr>
#> 1     3 Z1    Z1    Z1   
#> 2     3 Z2    Z2    Z2

Created on 2021-06-14 by the reprex package (v0.3.0)

If 'id' is always the first column, and the values in the remaining columns:

df[df$id %in% names(which(!tapply(grepl("X", as.matrix(df[-1])),
                                  rep(df[ , 1], ncol(df) - 1), any))), ]

This approach combines all the columns with paste and then rely on stringr to produce a vector with all the ids where 'X' is present.

library(tidyverse)
library(stringr)

df <- data.frame(id = rep(1:3, each = 2),
                 var1 = c("X1", "Y1", "Y2", "Y3", "Z1", "Z2"),
                 var2 = c("Y1", "X2", "Y2", "Y3", "Z1", "Z2"),
                 var3 = c("Y1", "Y2", "X1", "Y3", "Z1", "Z2"),
                 stringsAsFactors = FALSE)


system.time({df %>%
        group_by(id) %>%
        filter(all(reduce(.x = across(var1:var3, ~ !grepl("^X", .)), .f = `&`)))})
#>    user  system elapsed 
#>   0.022   0.001   0.023


#answer 
system.time({
criteria <- as.numeric(paste0(df$var1, df$var2, df$var3, '-', df$id) %>%
                {str_sub(.[str_detect(., 'X')], start = -1)} |>
                unique())

df_filtered <- filter(df, !id %in% criteria)
})
#>    user  system elapsed 
#>   0.002   0.000   0.001

df_filtered
#>   id var1 var2 var3
#> 1  3   Z1   Z1   Z1
#> 2  3   Z2   Z2   Z2

Created on 2021-06-15 by the reprex package (v2.0.0)

Another Base R solution, which I haven't seen mentioned yet. Makes use of modulo by the number of rows to quickly return the rows to remove:

df[!(df$id %in% df$id[(which(df=="X1" | df=="X2") %% nrow(df))]),]
id var1 var2 var3
5  3   Z1   Z1   Z1
6  3   Z2   Z2   Z2

It's fast, measured in microseconds:

library(microbenchmark)
microbenchmark(df[!(df$id %in% df$id[(which(df=="X1" | df=="X2") %% nrow(df))]),])
Unit: microseconds
min       lq     mean   median       uq     max
136.601 140.8505 165.7009 145.4515 172.9005 328.801 
Related