Довольно часто при анализе данных эмпирических исследований мы сталкиваемся с пропущенными значениями. Далеко не все методы (функции) в R корректно работают с пропущенными данными, поэтому наличие пропущенных значений в данных требует дополнительных манипуляций. При работе с пропущенными данными есть несколько вариантов: удалить их или заменить на какое либо значение (обычно это одна из мер центральной тенденции или мода). В этой заметке я рассмотрю сравнение производительности различных способов удаления пропущенных данных. Особенно актуально это выглядит с точки зрения работы с большими данными, где разница в несколько процентов даст ощутимое сокращение времени выполнения.

Для тестирования производительности нам понадобится пример данных. Я, конечно, мог бы взять уже готовый датасет, но мне всегда интереснее конструировать данные для примеров самому. Ниже приведена функция, которая генерирует таблицу данных, с заданным числом строк (nrow) и столбцов (ncol), а также долей пропущенных значений на один столбец (na.perc). В качестве генератора значений я взял функцию runif(), но это абсолютно не принципиально.

gen_data <- function(nrow = 10e3, ncol = 10, na.perc = 0.1) {
    m <- replicate(ncol, runif(nrow))
    for (col in 1:ncol) {
        idx <- sample(1:nrow, size = floor(nrow * na.perc))
        m[idx, col] <- NA
    }
    return(as.data.frame(m))
}

Создадим данные для дальнейшего тестирования.

dataset <- gen_data()

В R есть следующие функции для работы с пропущенными значениями: na.omit() и complette.cases() из пакета stats. Также можно использовать решение на основе примитива is.na(), которую я продемонстрирую ниже. Приведу пример кода, демонстрирующий эти способы:

na.omit(dataset)
dataset[complete.cases(dataset), ]
dataset[which(complete.cases(dataset)), ]
dataset[rowSums(is.na(dataset)) == 0, ]
dataset[which(rowSums(is.na(dataset)) == 0), ]

Все варианты, за исключением тех, что используют complete.cases(), основаны на применении функции is.na() (в том числе na.omit()) и фильтрации строк на её основе. Теперь сравним производительность этих способов. Для тестирования производительности я, традиционно, использую пакет microbenchmark. ниже приведены результаты тестирования.

library(microbenchmark)
microbenchmark(
    na.omit = na.omit(dataset),
    comp.case = dataset[complete.cases(dataset), ],
    comp.case.which = dataset[which(complete.cases(dataset)), ],
    rowsums = dataset[rowSums(is.na(dataset)) == 0, ],
    roswums.which = dataset[which(rowSums(is.na(dataset)) == 0), ]
)
#> Unit: microseconds
#>             expr    min     lq mean median     uq   max neval cld
#>          na.omit 5628.9 5713.0 6846 5838.1 6892.2 70642   100   c
#>        comp.case 2595.1 2623.5 3609 2640.0 2735.2 76965   100  b 
#>  comp.case.which  793.1  805.3 1030  815.5  828.9 12804   100 a  
#>          rowsums 2972.2 3005.4 3298 3027.2 3141.3  5012   100  b 
#>    roswums.which 1157.6 1185.5 1494 1208.1 1359.8  3192   100 a

Как видим из результатов сравнения, наилучшие результаты показывает вариант с использованием функций complate.cases() и which(), а наименее эффективным оказался na.omit(). Результаты вполне предсказуемы, если мы рассмотрим как работают эти функции.

na.omit() циклом проходит по всем столбцам и составляет список пропущенных значений, после чего их исключает. С увеличением количества столбцов в таблице na.omit() будет работать пропорционально дольше.

complete.cases() вызывает внутреннюю функцию, написанную на C специально для поиска пропущенных значений, поэтому работает значительно быстрее. Если на вход подаётся матрица или таблица возвращает, логичекий вектор равный количеству строк, который обозначает содержит ли строка пропущещные значения.

is.na() также вызывают внутренние функции, написанные на C и возвращает объект такой же размерности, но все ячейки отмечены как TRUE или FALSE в зависимости от того, является ли ячейка пропущенным значением. Поскольку нашей задачей является определение строк, содержащий пропущенные значения, то первым что пришло в голову было использование функции apply(), в сочетании с any(), но данный вариант работает крайне медленно (значительно медленнее na.omit()), поэтому далее мы его рассматривать не будем. Вместо apply() я использовал высокопроизводительную функцию rowSums() и отфильтровал те строки, где сумма равна 0 (логические значение преобразуются в 0 и 1, что позволяет их суммировать), т.е. строки, содержащие только значения FALSE.

Также ожидаемым является ускорение при использовании which(), т.к. вектор из номеров строк обрабатывается быстрее логического вектора, хотя бы потому, что числовой вектор имеет меньший размер.

В последнее время всё более популярным становится пакет dplyr, поэтому мы рассмотрим особенности работы с пропущенными данным в нём.

library(dplyr)
dataset2 <- tbl_df(dataset)
microbenchmark(
    na.omit = dataset2 %>% na.omit,
    comp.case = dataset2 %>% filter(complete.cases(.)),
    comp.case.which = dataset2 %>% slice(which(complete.cases(.))),
    rowsums = dataset2 %>% filter(rowSums(is.na(.)) == 0),
    roswums.which = dataset2 %>% slice(which(rowSums(is.na(.)) == 0))
)
#> Unit: microseconds
#>             expr    min     lq   mean median     uq   max neval cld
#>          na.omit 5405.1 5494.7 5830.3 5561.3 5794.0  7356   100   c
#>        comp.case 1015.1 1033.5 1165.8 1065.8 1176.3  2375   100 ab 
#>  comp.case.which  802.5  827.7  966.2  870.8  967.6  2844   100 a  
#>          rowsums 1398.7 1422.8 1690.0 1493.7 1594.5  3268   100 ab 
#>    roswums.which 1192.8 1218.1 2242.7 1294.0 1407.4 74486   100  b

По результатам бенчмарка мы видим в точности такую же картину, что и в предыдущем случае. Это объясняется тем, что мы по-прежнему работаем с классом data.frame.

Немного другая ситуация наблюдается при манипуляции с data.table:

library(data.table)
dataset3 <- setDT(dataset)
microbenchmark(
    na.omit = na.omit(dataset3),
    comp.case = dataset3[complete.cases(dataset3), ],
    comp.case.which = dataset3[which(complete.cases(dataset3)), ],
    rowsums = dataset3[rowSums(is.na(dataset3)) == 0, ],
    roswums.which = dataset3[which(rowSums(is.na(dataset3)) == 0), ]
)
#> Unit: milliseconds
#>             expr   min    lq  mean median    uq    max neval cld
#>          na.omit 2.689 2.769 3.096  2.873 3.070  4.860   100   b
#>        comp.case 1.194 1.233 1.363  1.272 1.353  3.900   100  a 
#>  comp.case.which 1.195 1.234 1.346  1.269 1.340  2.892   100  a 
#>          rowsums 1.608 1.683 2.827  1.835 2.152 79.154   100   b
#>    roswums.which 1.600 1.680 2.093  1.752 1.968 12.158   100  ab

Из результатов мы видим, во-первых, что различия стали не такими значительными и, во-вторых, что варианты с использованием which() и без него практически не различаются. Это подтверждает высокую оптимизацию data.table при работе с логическими векторами при фильтрации строк, а также циклами.