Меньше недели назад в журнале Хакер вышла авторская версия материала, посвященного фичам при использовании циклов при разработке на 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%, но при этом есть несколько нюансов:


  1. До вызова foreach у тебя уже должен быть зарегистрирован parallel backend. В R есть несколько популярных реализаций parallel backend doParallel, 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. Как правило, он создается один раз в программе и используется всеми функциями, которые могут с ним работать.


  1. Тебе надо явно указать, какие пакеты необходимо загрузить в рабочие потоки с помощью параметра .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"))

  1. Вывод на консоль в параллельном потоке не отображается на экране. Иногда это может здорово усложнить отладку, поэтому обычно сложный код сначала пишут без параллельности, а потом заменяют %do% на %dopar% либо перенаправляют вывод каждой итерации в свой файл с помощью функции sink.

Вместо выводов


  • При работе с большим объемом данных циклы не всегда оказываются лучшим выбором. Использование специализированных функций для выборки, агрегации и трансформации данных всегда эффективнее циклов.


  • R предлагает множество вариантов реализации циклов. Основное отличие классических for, while и repeat от группы функций на основе apply заключается в том, что последние возвращают значение.


  • Использование циклов foreach из одноименного внешнего пакета позволяет упростить написание циклов, гибко оперировать возвращаемыми итерациями значениями, а за счет подключения многопоточной обработки еще и здорово увеличить производительность решения.

WWW


Поделиться с друзьями
-->

Комментарии (8)


  1. pro100olga
    30.01.2017 10:23
    +1

    Спасибо за статью! Бывает непросто перестать мыслить циклами :)
    Было бы интересно увидеть список задач, где без циклов не обойтись. Например, у меня такой кейс: есть вектор с названиями файлов формата json, нужно последовательно их считывать, далее в каждом файле считывать несколько подсписков (для каждого файла кол-во подсписков разное). Сейчас это реализовано двумя циклами for: внешний для считывания файлов и внутренний для прохода по всем подспискам. Можно ли это реализовать более эффективно без циклов?


  1. impwx
    30.01.2017 11:02

    На моем ноутбуке расчеты занимают 39 секунд, хотя того же результата можно достичь за 0,009 секунды
    С точки зрения человека, далекого от R, это очень странная ситуация. Обработка массива данных всегда где-то под капотом сводится к циклу. Получается, что стандартная реализация цикла средствами языка несет столько накладных расходов, что выполняется в 4 тысячи раз медленнее «железного» цикла?

    По-моему, эта проблема должна решаться оптимизациями на стороне компилятора — разворачивание циклов, SIMD и другие хитрости, как это делает, например, компилятор C. А добавлением целого семейства специализированных функций выглядит, как костыль.


    1. Deosis
      30.01.2017 13:22

      Больше похоже на ленивое выполнение, когда в таблицу внесли запись вида «отдавать дополнительную колонку, в которой находится результат операции». А так как после этого данные никто и не читает, то реальной работы и не происходит.


      1. impwx
        30.01.2017 13:36

        Тогда получается, что сравнение вообще некорректно?


        1. StasCh
          02.02.2017 09:37
          +1

          R интерпретируемый язык, поэтому циклы и неоптимизированы, а групповые операции как раз всю оптимизацию внутри циклов и имеют. Поэтому в R важно понимать что делаешь.


          1. vladob
            05.02.2017 04:13

            Думается, последнее полезно при работе не только в R.


    1. dimm_ddr
      01.02.2017 18:39

      Нет, просто во втором случае все работает параллельно. И R сам по себе на эту параллельность заточен. Вполне возможно что обычный цикл даже толком не оптимизирован — он не используется для больших данных. Но последнее — это уже домыслы.


  1. vladob
    30.01.2017 13:56

    for(row in 1:nrow(testDF))
    testDF[row, 3] < — testDF[row, 1] + testDF[row, 2] # Ужас!

    Согласен. «Ужас!». Но ведь — не «Ужас-ужас!»

    Попробуйте «штатное» (т.е. без всяких библиотек) для R:

    system.time(testDF$c<-testDF$a+testDF$b)
    


    Будете приятно удивлены.