Select groups with only consecutive runs of a certain value

Viewed 119

I have data grouped by 'id', and a column 'x' that can be "yes", "no" or NA.

I want to keep only those 'id' where 'x' (1) contains two "yes", and (2) there are no "no" values between the "yes". NA between the two "yes" is fine.

Some toy data:

data <- data.frame(id = c(1,1,1,1,1,1,2,2,2,2,2,3,3,3,3,3,4,4,4,4,4,5,5,5,5,5),
                   x = c(NA,'yes',NA,'yes',NA,NA,NA,NA,'yes','yes',NA,'no', 'no',NA,NA,'yes',
                       'no','yes','no','yes','no', 'yes',NA, 'no','yes', 'no'))
   id    x
1   1 <NA>
2   1  yes # 1st yes
3   1 <NA>
4   1  yes # 2nd yes, only NA between, yes is considered as consecutive -> keep group 1 
5   1 <NA>
6   1 <NA>
7   2 <NA>
8   2 <NA>
9   2  yes  # 1st yes
10  2  yes  # 2nd yes, yes is consecutive -> keep group 2  
11  2 <NA>
12  3   no
13  3  yes  # 1st yes
14  3 <NA>
15  3 <NA>
16  3  yes  # 2nd yes -> keep group 3
17  4   no
18  4  yes # 1st yes
19  4   no # "no"
20  4  yes # 2nd yes. a "no" between the two 'yes' -> remove group
21  4   no
22  5  yes  # 1st yes
23  5 <NA>
24  5   no # "no"
25  5  yes # 2nd yes. a "no" between the two 'yes' -> remove group 
26  5   no

Desired Output

1   1 <NA>
2   1  yes
3   1 <NA>
4   1  yes
5   1 <NA>
6   1 <NA>
7   2 <NA>
8   2 <NA>
9   2  yes
10  2  yes
11  2 <NA>
12  3   no
13  3  yes
14  3 <NA>
15  3 <NA>
16  3  yes

id 4 and id 5 should be removed as they do not meet the criteria of two consecutive "yes" values for column 'x' per group 'id', irrespective of NA values between two yes values.

I tried using

data1<-data %>% group_by(id) %>% 
  mutate(x_lag = lag(x), 
         is_two_yes = x == 'yes' & x_lag == 'yes') %>% 
  filter(any(is_two_yes)) %>% 
  select(-is_two_yes,-x_lag) 
5 Answers
data <- data.frame(id = rep(1:5, each = 5),
                   x = c(NA, 'yes', NA, 'yes', NA,
                         NA, NA, NA, 'yes', 'yes',
                         NA, 'no', "yes", NA, 'yes', 
                         'no', 'yes', 'no', 'yes', NA, 
                         'yes', NA, 'no','yes', 'no'))

twoYes <- function(x){
  v <- c()              # Create an empty vector for data storage
  cum <- 0              # An cumulative index start at 0
  for (i in x){         # Run through the input vector
    if (i == "yes" & !is.na(i)){
      cum <- cum + 1      # if met "yes", cumulatively + 1
      v <- c(v, cum)      # store the value
    }else{
      if(i == "no" & !is.na(i)){
        cum <- 0          # if met "no",  restore to zero
        v <- c(v, cum)    # store the value
      }else{
        v <- c(v, cum)    # if met other conditions, retain value
      }                   # and store the value
    }
  }
  return(v)     # therefore, v > 1 means two continuous "yes" met
}

df <- data |> 
  group_by(id) |> 
  mutate(v = twoYes(x)) |> 
  filter(v > 1)        # this could be adjusted due to your needs
                       # for instance, to acquire three continuous 
                       # "yes", then filter(v > 2)

unique(df$id)       # in this example, id: 1, 2, 3 have two continuous "yes"

[1] 1 2 3

We can add a variable a to refer 2 --> yes , 1 --> no and 0 --> NA , then filter to exclude the rows having NA then using zoo::rollsum with window of 2's , so if we get the value 4 then this group has two consecutive yes

library(tidyverse)

data |>
   mutate(a = case_when(x == "yes" ~ 2 , x == "no" ~ 1 , TRUE ~ 0)) |>
   group_by(id) |> filter(a != 0) |>
   mutate(b = c(first(a) , zoo::rollsum(a , 2))) |>
   summarise(groups_to_keep = id[which(b == 4)]) -> gk

data |> filter(id %in% gk$groups_to_keep)

  • output
   id    x
1   1 <NA>
2   1  yes
3   1 <NA>
4   1  yes
5   1 <NA>
6   1 <NA>
7   2 <NA>
8   2 <NA>
9   2  yes
10  2  yes
11  2 <NA>

This relies only on lag and lead. To me it makes sense, since you're only aiming at filtering out id's where a no is lead and followed by two yes.

uneligible <- data %>% filter(!is.na(x)) %>% group_by(id) %>% 
  mutate(prev_x=dplyr::lag(x, default="none"),
         next_x=dplyr::lead(x, default="none"),
         is_uneligible=any(x=="no"&prev_x=="yes"&next_x=="yes")) %>% 
           dplyr::filter(is_uneligible) %>% 
           select(id) %>% unique 

# A tibble: 2 x 1
# Groups:   id [2]
id
<dbl>
  4
  5

result <- data %>% filter(!id %in% uneligible$id)

   id    x
1   1 <NA>
2   1  yes
3   1 <NA>
4   1  yes
5   1 <NA>
6   1 <NA>
7   2 <NA>
8   2 <NA>
9   2  yes
10  2  yes
11  2 <NA>
12  3   no
13  3   no
14  3 <NA>
15  3 <NA>
16  3  yes

EDIT if you want to keep only ids with at least two yes, you can use the following.

uneligible <- data %>% filter(!is.na(x)) %>% group_by(id) %>% 
  mutate(prev_x=dplyr::lag(x, default="none"),
         next_x=dplyr::lead(x, default="none"),
         is_uneligible=any(x=="no"&prev_x=="yes"&next_x=="yes")|sum(x %in% "yes")<2) %>% 
        dplyr::filter(is_uneligible) %>% dplyr::select(id) %>% unique 
result <- data %>% filter(!id %in% uneligible$id)

This will however filter out id=3 in your example, as your dput doesn't match your data.

> result
   id    x
1   1 <NA>
2   1  yes
3   1 <NA>
4   1  yes
5   1 <NA>
6   1 <NA>
7   2 <NA>
8   2 <NA>
9   2  yes
10  2  yes
11  2 <NA>

I want to keep only those 'id' where 'x' (1) contains two "yes", and (2) there are no "no" values between the "yes". NA between the two "yes" is fine.

This is unclear what to do in the case of more than two "yes" values. This answer assumes ids with only 2 "yes" values are allowed. If this is not the case, simply modify the transition matrix.

Note that I modified data at row 13 ("no" -> "yes") to match the output shown in the question.

First define a state transition matrix. Here the rows are the states and the columns are the transitions. The initial state is row 1 (initial). The state of each id will change depending on what x value is encountered (e.g., encountering a yes while in the initial state will cause the system to transition to state 2 (1yes), etc.)

m <- matrix(as.integer(c(2,4,3,3,1,3,3,4,1,2,3,4)), 4, 3, dimnames = list(c("initial", "1yes", "drop", "keep"), c("yes", "no", "NA")))
m
#>         yes no NA
#> initial   2  1  1
#> 1yes      4  3  2
#> drop      3  3  3
#> keep      3  4  4

Use Reduce to get the final state of each id in a grouping operation with data.table, keeping only those ids that end with 4 (keep):

library(data.table)

setDT(data)[
  , idx := match(x, c("yes", "no"), 3L)
][
  , .(x = if (Reduce(function(i, j) m[i, j], idx, 1L) == 4L) x else character(0)), id
]
#>     id    x
#>  1:  1 <NA>
#>  2:  1  yes
#>  3:  1 <NA>
#>  4:  1  yes
#>  5:  1 <NA>
#>  6:  1 <NA>
#>  7:  2 <NA>
#>  8:  2 <NA>
#>  9:  2  yes
#> 10:  2  yes
#> 11:  2 <NA>
#> 12:  3   no
#> 13:  3  yes
#> 14:  3 <NA>
#> 15:  3 <NA>
#> 16:  3  yes
library(dplyr)
data |>
  group_by(id) |> 
  filter(any(x[!is.na(x)] == 'yes' & lag(x[!is.na(x)]) == 'yes'))

#       id x    
#    <dbl> <chr>
#  1     1 NA   
#  2     1 yes  
#  3     1 NA   
#  4     1 yes  
#  5     1 NA   
#  6     1 NA   
#  7     2 NA   
#  8     2 NA   
#  9     2 yes  
# 10     2 yes  
# 11     2 NA 

Data:

data <- data.frame(id = c(1,1,1,1,1,1,2,2,2,2,2,3,3,3,3,3,4,4,4,4,4,5,5,5,5,5),
                   x = c(NA,'yes',NA,'yes',NA,NA,NA,NA,'yes','yes',NA,'no', 'no',NA,NA,'yes',
                       'no','yes','no','yes','no', 'yes',NA, 'no','yes', 'no'))
Related