Find last of several columns that is not NA (tidyverse)

Viewed 96

Not sure what I'm doing wrong but I'm struggling getting the index per row of the last column (among several columns) that is not NA.

Using tidyverse and across, I'm getting as many output columns as input columns where I'd expect one single output column with the index of the respective column.

dat <- data.frame(id = c(1, 2, 3),
                  x  = c(1, NA, NA),
                  y  = c(NA, NA, NA),
                  z  = c(3, 1, NA))

I tried the following (among others, inspired by this one: Return last data frame column which is not NA):

dat %>%
  mutate(last = across(-id, ~max.col(!is.na(.x), ties.method="last")))

Expected outcome would be:

  id  x  y  z  last
1  1  1 NA  3  3
2  2 NA NA  1  3
3  3 NA NA NA  NA
6 Answers

The problems with your current flow:

  1. across is going to pass one column at a time to the function/expression; your code needs a row or a matrix/frame. For this, across is not appropriate.

  2. Your desired output of NA for the last row is inconsistent with the logic: !is.na(.x) should return c(F,F,F), which still has a max. Your logic then requires a custom function, since you need to handle it differently.

Try this adaptation of max.col into a custom function:

max.col.notna <- function (m, ties.method = c("random", "first", "last")) {
    ties.method <- match.arg(ties.method)
    tieM <- which(ties.method == eval(formals()[["ties.method"]]))
    out <- .Internal(max.col(as.matrix(m), tieM))
    m[] <- !m %in% c(0,NA) # 'm[] <-' is required to maintain the matrix shape
    replace(out, rowSums(m) == 0, NA_integer_)
}

dat %>%
  mutate(last = max.col.notna(!is.na(select(., -id)), ties.method = "last"))
#   id  x  y  z last
# 1  1  1 NA  3    3
# 2  2 NA NA  1    3
# 3  3 NA NA NA   NA

Note: I've edited/changed the function several times, trying to ensure a consistent API to the intent of this custom function. As it stands now, the notna in the function name to me reflects a sense of "emptiness" (either 0 or NA). With this logic, the function is usable with logical (as here) and numeric data. Perhaps it's overkill, but I prefer APIs that operate consistently/predictably across input classes.

An R base solution:

dat$last = apply(dat[,2:4], 1, 
                 FUN = function(x) ifelse(max(which(is.na(x))) == length(x), NA, max(which(is.na(x)))+1 ))

dat

# id  x  y  z last
# 1  1  1 NA  3    3
# 2  2 NA NA  1    3
# 3  3 NA NA NA   NA

tidyverse isn't really suitable for row-wise operation. Most of the times reshaping the data into long format (as shown in @Rui Barradas answer) is a good approach.

Here is one way using rowwise keeping the data wide.

library(dplyr)

dat %>%
  rowwise() %>%
  mutate(last = {ind = which(!is.na(c_across(x:z))); 
                if(length(ind)) tail(ind, 1) else NA})

#    id     x   y        z  last
#  <dbl> <dbl> <lgl> <dbl> <int>
#1     1     1 NA        3     3
#2     2    NA NA        1     3
#3     3    NA NA       NA    NA

You want to use c_across() and rowwise() to do this. rowwise() works similar to group_by_all(), except it is more explicit. c_across() creates flat vectors out of columns (whereas across() creates tibbles).

If we first define a function seperately to pull out the last non-NA value, or return NA if there are none:

get_last <- function(x){
  y <- c(NA,which(!is.na(x)))
  y[length(y)]
}


We can then apply that function c_across() the variables we need, but only after converting into a rowwise_df using rowwise()

dat %>%
  rowwise() %>%
  mutate(last = get_last(c_across(x:z)))

base R

df <- data.frame(id = c(1, 2, 3),
                        x  = c(1, NA, NA),
                        y  = c(NA, NA, NA),
                        z  = c(3, 1, NA))


df$last <- apply(df[-1], 1, function(x) max(as.vector(!is.na(x)) * seq_len(length(x))))
df$last[df$last == 0] <- NA
df
#>   id  x  y  z last
#> 1  1  1 NA  3    3
#> 2  2 NA NA  1    3
#> 3  3 NA NA NA   NA

Created on 2020-12-29 by the reprex package (v0.3.0)

Starting with a vector of NAs, you could step through each col and if the given element passes your check_fun returning TRUE, assign the index of that col to that element. The difference from the other answers here is that this does not check the condition row-wise or create a matrix from the data. Not sure whether creating two new temp vectors for each column is better/worse than just converting the entire data to a matrix first though.

library(tidyverse) # purrr and dplyr

last_matching_ind <- function(dat, check_fun){
  check_fun <- as_mapper(check_fun)
  reduce2(dat, seq_along(dat), .init = NA_integer_,
          function(prev, dat, ind) if_else(check_fun(dat), ind, prev) )
}

dat %>% 
  mutate(last = last_matching_ind(dat[-1], ~ !is.na(.x)))

#   id  x  y  z last
# 1  1  1 NA  3    3
# 2  2 NA NA  1    3
# 3  3 NA NA NA   NA
Related