Меньше недели назад в журнале Хакер вышла авторская версия материала, посвященного фичам при использовании циклов при разработке на R. По согласованию с Хакером, мы делимся полной версией первой статьи. Вы узнаете о том, как правильно писать циклы при обработке больших объемов данных.
Примечание: орфография и пунктуация автора сохранены.
Во многих языках программирования циклы служат базовыми строительными блоками, которые используются для любых повторяющихся задач. Однако в R чрезмерное или неправильное использование циклов может привести к ощутимому падению производительности — при том, что способов написания циклов в этом языке необычайно много.
Сегодня рассмотрим особенности применения штатных циклов в R, а также познакомимся с функцией foreach
из одноименного пакета, которая предлагает альтернативный подход в этой, казалось бы, базовой задаче. С одной стороны, foreach
объединяет лучшее из штатной функциональности, с другой — позволяет с легкостью перейти от последовательных вычислений к параллельным с минимальными изменениями в коде.
О циклах
Начнем с того, что часто оказывается неприятным сюрпризом для тех, кто переходит на R с классических языков программирования: если мы хотим написать цикл, то стоит перед этим на секунду задуматься. Дело в том, что в языках для работы с большим объемом данных циклы, как правило, уступают по эффективности специализированным функциям запросов, фильтрации, агрегации и трансформации данных. Это легко запомнить на примере баз данных, где большинство операций производится с помощью языка запросов SQL, а не с помощью циклов.
Чтобы понять, насколько важно это правило, давай обратимся к цифрам. Допустим, у нас есть очень простая таблица из двух столбцов a
и b
. Первый растет от 1 до 100 000, второй уменьшается со 100 000 до 1:
testDF <- data.frame(a = 1:100000, b = 100000:1)
Если мы хотим посчитать третий столбец, который будет суммой первых двух, то ты удивишься, как много начинающих R-разработчиков могут написать код такого вида:
for(row in 1:nrow(testDF))
testDF[row, 3] <- testDF[row, 1] + testDF[row, 2] # Ужас!
На моем ноутбуке расчеты занимают 39 секунд, хотя того же результата можно достичь за 0,009 секунды, воспользовавшись функцией для работы с таблицами из пакета dplyr
:
testDF <- testDF %>% mutate(c = a + b)
Основная причина такой серьезной разницы в скорости заключается в потере времени при чтении и записи ячеек в таблице. Именно благодаря оптимизациям на этих этапах и выигрывают специальные функции. Но не надо списывать в утиль старые добрые циклы, ведь без них все еще невозможно создать полноценную программу. Давай посмотрим, что там с циклами в R.
Классические циклы
R поддерживает основные классические способы написания циклов:
for
— самый распространенный тип циклов. Синтаксис очень прост и знаком разработчикам на различных языках программирования. Мы уже пробовали им воспользоваться в самом начале статьи.for
выполняет переданную ему функцию для каждого элемента.
# Напечатаем номера от 1 до 10 for(i in 1:10) print(i) # Напечатаем все строки из вектора strings strings <- c("Один", "Два", "Три") for(str in strings) print(str)
Чуть менее распространенные
while
иrepeat
, которые тоже часто встречаются в других языках программирования. Вwhile
перед каждой итерацией проверяется логическое условие, и если оно соблюдается, то выполняется итерация цикла, если нет — цикл завершается:
while(cond) expr
В repeat
цикл повторяется до тех пор, пока в явном виде не будет вызван оператор break
:
repeat expr
Стоить отметить, что for
, while
и repeat
всегда возвращают NULL, — и в этом их отличие от следующей группы циклов.
Циклы на основе apply
apply
, eapply
, lapply
, mapply
, rapply
, sapply
, tapply
, vapply
— достаточно большой список функций-циклов, объединенных одной идеей. Отличаются они тем, к чему цикл применяется и что возвращает.
Начнем с базового apply
, который применяется к матрицам:
apply(X, MARGIN, FUN, ...)
В первом параметре (X
) указываем исходную матрицу, во втором параметре (MARGIN
) уточняем способ обхода матрицы (1 — по строкам, 2 — по столбцам, с(1,2) — по строкам и столбцам), третьим параметром указываем функцию FUN, которая будет вызвана для каждого элемента. Результаты всех вызовов будут объединены в один вектор или матрицу, которую функция apply
и вернет в качестве результирующего значения.
Например, создадим матрицу m
размером 3 х 3.
m <- matrix(1:9, nrow = 3, ncol = 3)
print(m)
[,1] [,2] [,3]
[1,] 1 4 7
[2,] 2 5 8
[3,] 3 6 9
Попробуем функцию apply
в действии.
apply(m, MARGIN = 1, FUN = sum) # Сумма ячеек для каждой строчки
[1] 12 15 18
apply(m, MARGIN = 2, FUN = sum) # Сумма ячеек для каждого столбца
[1] 6 15 24
Для простоты я передал в apply
существующую функцию sum
, но ты можешь использовать свои функции — собственно, поэтому apply
и является полноценной реализацией цикла. Например, заменим сумму нашей функцией, которая сначала производит суммирование и, если сумма равна 15, заменяет возвращаемое значение на 100.
apply(m, MARGIN = 1, # Вызов нашей функции для каждой строчки
FUN = function(x) # Определяем нашу функцию прямо в вызове apply
{
s <- sum(x) # Считаем сумму
if (s == 15) # Если сумма равна 15, то поменяем ее на 100
s <- 100
(s)
}
)
[1] 12 100 18
Другая распространенная функция из этого семейства — lapply
.
lapply(X, FUN, ...)
Первым параметром передается список или вектор, а вторым — функция, которую надо вызвать для каждого элемента. Функции sapply
и vapply
— это обертки вокруг lapply
. Первая пытается привести результат к вектору, матрице или массиву. Вторая добавляет проверку типов возвращаемого значения.
Достаточно распространен такой способ применения sapply
, как работа с колонками. Например, у нас есть таблица
data <- data.frame(co1_num = 1, col2_num = 2, col3_char = "a", col4_char = "b")
При передаче sapply
таблицы она будет рассматриваться как список колонок (векторов). Поэтому, применив sapply
к нашему data.frame
и указав в качестве вызываемой функции is.numeric
, мы проверим, какие столбцы являются числовыми.
sapply(data, is.numeric)
co1_num col2_num col3_char col4_char
TRUE TRUE FALSE FALSE
Выведем на экран только столбцы с числовыми значениями:
data[,sapply(data, is.numeric)]
co1_num col2_num
1 1 2
Циклы, основанные на apply
, отличаются от классических тем, что возвращается результат работы цикла, состоящий из результатов каждой итерации.
Помнишь тот медленный цикл, что мы написали в самом начале с помощью for
? Большая часть времени терялась на то, что на каждой итерации в таблицу записывались результаты. Напишем оптимизированную версию с использованием apply
.
Применим apply
к первоначальной таблице, выбрав обработку по строчкам, и в качестве применяемой функции укажем базовую суммирующую функцию sum
. В итоге apply
вернет вектор, где для каждой строки будет указана сумма ее колонок. Добавим этот вектор в качестве нового столбца первоначальной таблице и получим искомый результат:
a_plus_b <- apply(testDF, 1,sum)
testDF$c <- a_plus_b
Замер времени исполнения показывает 0,248 секунды, что в сто раз быстрее первого варианта, но все еще в десять раз медленнее функций операций с таблицами.
foreach
foreach
— не базовая для языка R функция. Соответствующий пакет необходимо установить, а перед вызовом подключить:
install.packages("foreach") # Установка пакета на компьютер (один раз)
library(foreach) # Подключение пакета
Несмотря на то что foreach
— сторонняя функция, на сегодняшний день это очень популярный подход к написанию циклов. foreach
был разработан одной из самых уважаемых в мире R компанией — Revolution Analytics, создавшей свой коммерческий дистрибутив R. В 2015 году компания была куплена Microsoft, и сейчас все ее наработки входят в состав Microsoft SQL Server R Services. Впрочем, foreach
представляет собой обычный open source проект под лицензией Apache License 2.0.
Основные причины популярности foreach
:
- синтаксис похож на
for
— как я уже говорил, самый популярный вид циклов; foreach
возвращает значения, которые собираются из результатов каждой итерации, при этом можно определить свою функцию и реализовать любую логику сбора финального значения цикла из результатов итераций;- есть возможность использовать многопоточность и запускать итерации параллельно.
Начнем c простого. Для чисел от 1 до 10 на каждой итерации число умножается на 2. Результаты всех итераций записываются в переменную result в виде списка:
result <- foreach(i = 1:10) %do%
(i*2)
Если мы хотим, чтобы результатом был не список, а вектор, то необходимо указать c
в качестве функции для объединения результатов:
result <- foreach(i = 1:10, .combine = "c") %do%
(i*2)
Можно даже просто сложить все результаты, объединив их с помощью оператора +
, и тогда в переменную result
будет просто записано число 110:
result <- foreach(i = 1:10, .combine = "+") %do%
(i*2)
При этом в foreach
можно указывать одновременно несколько переменных для обхода. Пусть переменная a
растет от 1 до 10, а b
уменьшается от 10 до 1. Тогда мы получим в result
вектор из 10 чисел 11:
result <- foreach(a = 1:10, b = 10:1, .combine = "c") %do%
(a+b)
Итерации циклов могут возвращать не только простые значения. Допустим, у нас есть функция, которая возвращает data.frame
:
customFun <- function(param)
{
data.frame(param = param, result1 = sample(1:100, 1), result2 = sample(1:100, 1))
}
Если мы хотим вызвать эту функцию сто раз и объединить результаты в один data.frame
, то в .combine для объединения можно использовать функцию rbind
:
result <- foreach(param = 1:100,.combine = "rbind") %do%
customFun(param)
В результате в переменной result
у нас собрана единая таблица результатов.
В .combine
возможно также использовать свою собственную функцию, причем с помощью дополнительных параметров можно оптимизировать производительность, если твоя функция умеет принимать больше чем два параметра сразу (в документации foreach
есть описание параметров .multicombine
и .maxcombine
).
Одно из главных преимуществ foreach
заключается в легкости перехода от последовательной обработки к параллельной. Фактически этот переход осуществляется заменой %do%
на %dopar%
, но при этом есть несколько нюансов:
До вызова
foreach
у тебя уже должен быть зарегистрирован parallel backend. В R есть несколько популярных реализаций parallel backenddoParallel
,doSNOW
,doMC
, и у каждого есть свои особенности, но предлагаю ради простоты выбрать первый и написать несколько строчек кода для его подключения:
library(doParallel) # Загружаем библиотеку в память cl <- makeCluster(8) # Создаем «кластер» на восемь потоков registerDoParallel(cl) # Регистрируем «кластер»
Если сейчас вызвать цикл из восьми итераций, каждая из которых просто ждет одну секунду, то будет видно, что цикл отработает за одну секунду, так как все итерации будут запущены параллельно:
system.time({
foreach(i=1:8) %dopar% Sys.sleep(1) #
})
user system elapsed
0.008 0.005 1.014
После использования parallel backend можно остановить:
stopCluster(cl)
Нет никакой необходимости каждый раз перед foreach
создавать, а затем удалять parallel backend. Как правило, он создается один раз в программе и используется всеми функциями, которые могут с ним работать.
- Тебе надо явно указать, какие пакеты необходимо загрузить в рабочие потоки с помощью параметра
.packages
.
Например, ты хочешь на каждой итерации создавать файл с помощью пакета readr
, который загрузили в память перед вызовом foreach
. В случае последовательного цикла (%do%
) все отработает без ошибок:
library(readr)
foreach(i=1:8) %do%
write_csv(data.frame(id = 1), paste0("file", i, ".csv"))
При переходе на параллельную обработку (`%dopar%`) цикл закончится с ошибкой:
library(readr)
foreach(i=1:8) %do%
write_csv(data.frame(id = 1), paste0("file", i, ".csv"))
Error in write_csv(data.frame(id = 1), paste0("file", i, ".csv")) :
task 1 failed - "could not find function "write_csv""
Ошибка возникает, поскольку внутри параллельного потока не загружен пакет readr
. Исправим эту ошибку с помощью параметра .packages
:
foreach(i=1:8, .packages = "readr") %dopar%
write_csv(data.frame(id = 1), paste0("file", i, ".csv"))
- Вывод на консоль в параллельном потоке не отображается на экране. Иногда это может здорово усложнить отладку, поэтому обычно сложный код сначала пишут без параллельности, а потом заменяют
%do%
на%dopar%
либо перенаправляют вывод каждой итерации в свой файл с помощью функцииsink
.
Вместо выводов
При работе с большим объемом данных циклы не всегда оказываются лучшим выбором. Использование специализированных функций для выборки, агрегации и трансформации данных всегда эффективнее циклов.
R предлагает множество вариантов реализации циклов. Основное отличие классических
for
,while
иrepeat
от группы функций на основеapply
заключается в том, что последние возвращают значение.
- Использование циклов
foreach
из одноименного внешнего пакета позволяет упростить написание циклов, гибко оперировать возвращаемыми итерациями значениями, а за счет подключения многопоточной обработки еще и здорово увеличить производительность решения.
WWW
Комментарии (8)
impwx
30.01.2017 11:02На моем ноутбуке расчеты занимают 39 секунд, хотя того же результата можно достичь за 0,009 секунды
С точки зрения человека, далекого от R, это очень странная ситуация. Обработка массива данных всегда где-то под капотом сводится к циклу. Получается, что стандартная реализация цикла средствами языка несет столько накладных расходов, что выполняется в 4 тысячи раз медленнее «железного» цикла?
По-моему, эта проблема должна решаться оптимизациями на стороне компилятора — разворачивание циклов, SIMD и другие хитрости, как это делает, например, компилятор C. А добавлением целого семейства специализированных функций выглядит, как костыль.Deosis
30.01.2017 13:22Больше похоже на ленивое выполнение, когда в таблицу внесли запись вида «отдавать дополнительную колонку, в которой находится результат операции». А так как после этого данные никто и не читает, то реальной работы и не происходит.
dimm_ddr
01.02.2017 18:39Нет, просто во втором случае все работает параллельно. И R сам по себе на эту параллельность заточен. Вполне возможно что обычный цикл даже толком не оптимизирован — он не используется для больших данных. Но последнее — это уже домыслы.
vladob
30.01.2017 13:56for(row in 1:nrow(testDF))
testDF[row, 3] < — testDF[row, 1] + testDF[row, 2] # Ужас!
Согласен. «Ужас!». Но ведь — не «Ужас-ужас!»
Попробуйте «штатное» (т.е. без всяких библиотек) для R:
system.time(testDF$c<-testDF$a+testDF$b)
Будете приятно удивлены.
pro100olga
Спасибо за статью! Бывает непросто перестать мыслить циклами :)
Было бы интересно увидеть список задач, где без циклов не обойтись. Например, у меня такой кейс: есть вектор с названиями файлов формата json, нужно последовательно их считывать, далее в каждом файле считывать несколько подсписков (для каждого файла кол-во подсписков разное). Сейчас это реализовано двумя циклами for: внешний для считывания файлов и внутренний для прохода по всем подспискам. Можно ли это реализовать более эффективно без циклов?