Довольно часто при анализе данных эмпирических исследований мы сталкиваемся с проущенными значениями. Далеко не все методы (функции) в 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(), которую я продемонстрирую ниже. Для тестирования производительности я, традиционно, использую пакет 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 5943.8 6086.8 6755 6608.8 6770.1 32436 100 c
#> comp.case 2600.4 2620.7 3412 2636.8 3310.2 32470 100 b
#> comp.case.which 788.4 800.5 1714 809.9 853.6 27413 100 a
#> rowsums 2968.1 2998.7 3617 3106.5 3731.5 28739 100 b
#> roswums.which 1149.7 1175.0 1450 1195.1 1869.5 2197 100 a
Как видим из результатов сравнения, наилучшие результаты показывает выриант с использованием функций complate.cases() и which(), а наименее эффективным na.omit(). Резльтутаы вполне предсказуемы, если мы рассмотрим как работают эти функции. na.omit() циклом проходит по всем столбцам и составляет список пропущенных занчений, после чего их исключает. conplate.cases() вызыввает внтреннюю функцию, написанную на C, поэтому работает значительно быстрее. is.na() и rowSums() тажке вызывают внутренние функции, написанные на C. Также ожидаемым ускорение при использовании which(), т.к. вектор и номеров строк обрабатывается быстрее логического вектора, хотя бы потому, что числовой вектор имеет меньший размер.
Немного другая ситуация наблюдается при манипуляции с data.table:
library(data.table)
datset <- setDT(dataset)
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: milliseconds
#> expr min lq mean median uq max neval cld
#> na.omit 2.635 2.658 3.202 2.674 3.366 35.223 100 b
#> comp.case 1.140 1.170 1.275 1.180 1.199 2.147 100 a
#> comp.case.which 1.157 1.170 1.605 1.180 1.194 34.800 100 a
#> rowsums 1.565 1.585 2.236 1.606 2.380 34.184 100 ab
#> roswums.which 1.555 1.567 1.846 1.590 2.350 3.162 100 a
Как видим, во-первых, различия стали не такими значительными и, во-вторых, варианты с использованием which() и без него практически не различаются. Это подтверждает высокую оптимизацию data.table при работе с логическими векторами при фильтрации строк, а также циклами.