Автор статьи: Дмитрий Володин

Analytics Engineer в Trafficstars

"На небе только и разговоров, что о функциональном программировании."

Всем привет. Меня зовут Дмитрий Володин, я Analytics Engineer в TrafficStars. Сегодня я хочу рассказать вам о приёмах ФП в R. Исходить я постараюсь из более-менее реальных задач, а не учебных, чтобы показать, что элементам ФП вполне есть место в вашем ящике с инструментами.

Структуры данных в R

Надо немного сказать про структуры данных в R и про то, как объекты хранятся в памяти. В R нет скалярных структур. Даже если сделать x <- 42, всё равно это будет вектор, просто с длиной 1. И он хранится в определённой области памяти. Любое изменение с ним ведёт к тому, что весь измененный вектор будет храниться уже в другой области памяти.

Списки ведут себя немного иначе. Списки - это коллекция векторов (все датафреймы и прочее - просто вариации списков по сути). Список - сам по себе занимает область памяти, в которой хранятся ссылки на элементы списка. Если изменить элемент списка - и элемент и сам список сменят прописку. Неизменённые объекты останутся на своих местах. Довольно похоже на привычную вещь в ФП: объекты по большей части имутабельны.

Всё это ведёт к тому, что менять что-то поэлементно с последующим постоянным присваиванием в переменную крайне неэффективно. Потому что хоть в R и есть сборщик мусора, но он может не успевать вычищать все области, на которые не ссылается ни один объект в вашей текущей сессии. Тут на помощь приходит ФП с отображениями списков, рекурсиями и прочими заменами циклов.

*Надо оговориться, что под капотом в любом случае (если не явно, то на уровне машины) у вас будет цикл. Но цикл оптимизированный и написанный со знанием дела специалистами в области computer science. Можно и нужно им довериться.

Функции высших порядков

Если мы посмотрим на синтаксис создания функции в R

my_func <- funciton(a, b = 1) {
	a / b - b
}

то заметим, что они создаются также, как и другие объекты: оператором присваивания. Функции в R - граждане первого класса и с ними можно делать тоже самое, что и с другими объектами. Например, передавать в качестве аргументов в другие функции. Функции, которые используют другие функции в качестве аргументов, называются функциями высшего порядка.

В R есть классические функции высших порядков: Map, Reduce, Filter. Я специально не буду рассматривать *apply функции, потому что про них довольно много сказано.

  • Map(f, ...) (можно перевести как отображение) - применение функции f к элементам списков в ... поэлементно. Возвращает список длиной согласно правилам рециркуляции векторов.

  • Reduce(f, x, init, right = FALSE, accumulate = FALSE) - редукция списка x через функцию f. Функция принимает два аргумента. Первый - результат вычисления функции на предыдущей операции, второй - текущий элемент списка. Проще понять будет на примере.

  • Filter(f, x) - фильтрация списка. Остаются только те элементы, для которых результат применения f возвращает TRUE.

Большинство функций в R векторизовано. То есть применяется поэлементно и так. Например складывать два вектора не надо в цикле поэлементно. Достаточно просто написать v1 + v2 и элементы векторов сложатся. Но существуют функции, которые принимают один аргументы. Именно для таких случаев и существуют функции высших порядков. Как всегда, проще на примере.

# созадим сразу список с результатами кластеризации по списку с количеством центроидов
kmeans_results <- Map(kmeans, list(mtcars), 2:6)

К этому списку потом можно применять плечевой метод и на его результатах выбрать подходящее количество центроидов.

Знающие люди могут спросить: "Но ведь если посмотреть исходный код функций, там всё равно будут циклы? И если так, зачем вот эти сложные концепции, когда можно просто написать цикл в лоб?". Ну во-первых, написать цикл в лоб не так просто, как кажется, особенно учитывая работу с памятью в R. Наверняка на цикл её уйдёт больше, а может ещё и времени тоже. А во-вторых, писать цикл просто-напросто дольше.

Можно посчитать с помощью пакета microbenchmark. В целом решения с циклом и без него одинаковы по времени. Но вариант с Map выглядит намного проще, лаконичнее и понятнее. По крайней мере с опытом точно проще писать мапы, а не циклы.

Map(kmeans, list(mtcars), 1:6)

my_func <- function(df, x) {
  cl_list <- list()
  length(cl_list) <- x
  for (i in seq_len(x)) {
    cl_list[[i]] <- kmeans(df, i)
  }
  return(cl_list)
}

microbenchmark(
  Map(kmeans, list(mtcars), 1:6),
  my_func(mtcars, 6)
) %>% 
  as_tibble() %>% 
  mutate(time = time / 10e9) %>% 
  ggplot(aes(x = expr, y = time, fill = expr))+
  geom_boxplot()+
  coord_flip()+
  scale_fill_manual(values = c('#FF9E18', '#7D62F4'))+
  theme_bw()+
  theme(legend.position = 'bottom', axis.text.y.left = element_blank())

Теперь о Reduce. Если нужно вычислить вектор, где каждый последующий элемент вычисляется на основе расчёта предыдущего, то можно и нужно использовать Reduce. Например при расчёте факториала используются результаты на предыдущих итерациях: факториал 5 равен 5, умноженному на факториал 4.

Reduce(`*`, 1:5)
1 * 2 == 2
2 * 3 == 6
6 * 4 == 24
24 * 5 == 120

После самого вызова Reduce я раскрыл, как именно происходит расчёт. Заодно разберёмся, что за функция используется в первом аргументе.

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

`*`(24, 5)

Ну а под вызовом Reduce я расписал, что происходит на каждом шаге. Сначала перемножаются первые два элемента (получается 2). Этот результат затем перемножается с третьим элементом (получается 6). И так далее.

Если в Reduce передать в аргумент accumulate значение TRUE, то Reduce вернёт все промежуточные результаты. А если передать в аргумент init какое-то значение, то оно будет использоваться в качестве первого аргумента в первом расчёте.

Filter(\(x) x %% 2 == 0, x = as.numeric(1:20)) |>
  Reduce(`*`, x = _, accumulate = TRUE)

Здесь заодно есть и оператор конвейера (|>) и лямбда (\(x)). Об этом и поговорим в следующем разделе.

Немного про Filter. Пример выше исключительно учебный и фильтровать простые векторы так не стоит совсем. В индекс вектора можно передавать logical вектор такой же длины, тогда результатом будет вектор, состоящий из элементов исходного, для индексов которых значения в logical векторе TRUE

# вот так гораздо лучше
x <- 1:20
x[x %% 2 == 0]

Конвейер, лямбда и снижение количества переменных.

Пример выше преобразует изначальный вектор 1:20 в произведение всех чётных его членов с сохранением промежуточных элементов. Подведу к конвейерам издалека на этом примере. Начнём с того, что наделаем кучу переменных.

starting_vector <- 1:10
numeric_vector <- as.numeric(x = starting_vector)
filtered_vector <- Filter(\(x) x %% 2 == 0, x = numeric_vector)
reduced_vector <- Reduce(`*`, x = filtered_vector, accumulate = TRUE)

На самом деле это хрестоматийно плохой код, но проработать будущие ошибки лучше до мёрджа их в мастер.

Итак, мы тут создали три переменные, единственный смысл существования которых - расчёт четвёртой. Да, их можно удалить потом функцией rm. И сборщик мусора в какой-то момент освободит область памяти, которую они занимали. Но это дополнительная сложность в написании кода, с которой незачем сталкиваться аналитикам.

Теперь вспомним, что в аргументы функций можно передавать не просто значение или переменную с подходящим значением, а прямо результат вызова другой функции.

# здесь в фильтр сразу передаём вектор с изменённым типом, просто пример
Filter(\(x) x %% 2 == 0, as.numeric(1:20))

# а это финал, тут в аргумент x передаём предыдущую строчку
reduced_vector <- Reduce(`*`, x = Filter(\(x) x %% 2 == 0, as.numeric(1:20)), accumulate = TRUE)

Итак можно делать со сколь угодно глубокой вложенностью. К сожалению, так сильно страдает читаемость кода, потому что в основном люди читают сверху вниз и слева направо. И получается, что этот вызов будет прочитан с конца: сначала Reduce, а уже потом Filter. Для композиции из большого количества вызовов это превратится в жуткое нечитаемое месиво. Зато здесь создаётся только одна переменная.

Чтобы наш код был также понятен, как в первом примере, но при этом не имел кучи промежуточных переменных, существует оператор конвейера. До четвёртой версии R он был только в дополнительных пакетах (пусть и очень популярных; в этой же статье я использую %>% из пакета magrittr), но сейчас есть в стандартном наборе инструментов: |>.

f(g(h(x)))
# тоже самое, что и
x |> h() |> g() |> f()

# для передачи предыдщуего результата в конкретный аргумент 
# следующей функции используется placeholder _
# без такого явного указания значение из вызова слева передаётся
# в первый аргумент
TRUE |>
  sum(c(NA, 1:10), na.rm = _)

Анонимные функции я добавил в этот же раздел потому, что они тоже позволяют создавать меньше сущностей. Анонимной функцией (или лямбдой в качестве дани уважения лямбда-исчислению) называется такая функция, аргументы и тело которой не присваивается ни в какую переменную.

function(x) x + 1
# или
\(x) x + 1

Сверху вариант анонмной функции на свободном выпасе. Толку от такой записи мало, потому что она должна быть вызвана, а здесь она пропадает сразу после объявления. И есть три варианта использования (для экономии чернил я буду использовать второй вариант синтаксиса, они абсолютно равнозначны):

# традиционный: присваиваем в переменную
succ <- \(x) x + 1
succ(1) == 2

# извращённый: вызываем на месте
(\(x) x + 1)(1)
(\(x) x + 1)(1) == 2

# стандартный: в аргументе функций высшего порядка
Map(\(x) x + 1, 1:10)

Что нужно знать про анонимные функции:

  1. В отличие от того же Python, лямбды в R могут состоять из более чем одной строчки, если тело функции обернуть в фигурные скобки.

  2. В тело функции можно запихать всё, что угодно. Можно плюнуть на функциональный подход и налепить там чтения с диска, запись в файл, логирование и прочие побочные эффекты, что противоречит подходу "чистых функций"

  3. Но делать я так крайне не советую, лучше всё-таки функцию объявить заранее и использовать её к месту. Так вы возможно ещё и познаете прелести JIT компиляции в R.

Резюмирую: анонимные функции хороши, когда готовой нет, а писать и сохранять коротенькую функцию - нет смысла. Но длинные и сложные функции по всем правилам лучше сохранить в отдельную переменную для последующего вызова.

Функции возвращают функции

Напоминаю, что функции в R - граждане первого сорта. Значит их можно возвращать в результате выполнения других функций. Я ну буду использовать набивший оскомину вариант со сложением двух чисел или другими арифмитическими операциями. Вместо этого немного усложню

Map(\(x) \(df) kmeans(df, x), 1:6) %>% 
  Map(\(f) f(mtcars), .)

Здесь первая лямбда для каждого элемента вектора 1:10 возвращает функцию. Возвращаемая функция принимает в качестве аргумента датафрейм и применяет к нему метод k-средних с количеством центроидов, равных соответствующему значению элемента вектора 1:10. Ну а далее, чтобы не быть голословным, мы этот список функций отправим по конвейеру (уже из magrittr, он чуть более функциональный в общем плане) в следующее отображение, где применим эту функцию на датафрейм mtcars.

Функции, возвращающие функции, удобны для разработки. Написав вот так:

# Да, это классический вариант с add
add <- function(x, y) function(y) x + y

# > add(1)
# function(y) x + y
# <environment: 0x1544b09e8>

вы получаете функцию, в которую можно передавать значение в один аргумент (вместо указанных двух) и она в итоге не вернёт ошибку, а вернёт функцию, в которую также можно передать значение.

add(1)(2) == 3

Такая запись может быть даже проще читается, чем два подряд Map:

(\(df, n) \(n) kmeans(df, n))(mtcars) |>
  Map(f = _, 1:6)

Здесь мы объявляем лямбдой функцию от двух переменных df и n, передаём в первый аргумент значение mtcars и получаем функцию от одного аргумента. Её отправляем по конвейеру в Map, где получаем список результатов kmeans по на набору центроидов.

Сокращение количества аргументов путём последовательного возвращения функций от одного аргумента называется каррированием или каррингом по имени математика Хаскеля Карри.

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

library(purrr)

partial(kmeans, x = mtcars) %>% 
  map(1:6, .)

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

partial(kmeans, x = mtcars, centers = 5)()

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

sum_of_addition <- compose(`+`, sum)
sum_of_addition(1:10, 10:1)

Функция выше складывает два вектора поэлементно а затем результат суммирует.

Рекурсия

Говоря о ФП, невозможно обойти стороной рекурсию. Это довольно распространённый приём в преимущественно функциональных языках. Особенно в чистых, где вообще нет циклов и рекурсия является одним из немногих способов итерироваться (не считая функций высшего порядка). Концепцию рекурсии на курсе по Scala на курсере её создатель объясняет на втором уроке уже буквально. Я же этим блоком хочу сразу показать, что нельзя так огульно бросаться в ФП в R.

Итак, рекурсия - это обращение функции к самой себе. Это мощный и лаконичный приём, для понимания которого может понадобится время. Но как и всё мощное, надо к его использованию подходить с умом. Я приведу классические примеры с факториалом и вычислением n-ого элемента последовательности Фибоначчи.

straight_recursion_factorial <- function(n) {
  if (n <= 1) return(1)
  return(n * straight_recursion_factorial(n - 1))
}

# > straight_recursion_factorial(10)
# [1] 3628800

Здесь решение буквально в лоб. Факториал n равен факториалу (n-1) умноженному на n. Потому мы явно вызываем объявляемую функцию в её же теле. Давайте разберём это построчно

straight_recursion_factorial(10) == 10 * straight_recursion_factorial(9)
straight_recursion_factorial(9) == 9 * straight_recursion_factorial(8)
straight_recursion_factorial(8) == 8 * straight_recursion_factorial(7)
straight_recursion_factorial(7) == 7 * straight_recursion_factorial(6)
straight_recursion_factorial(6) == 6 * straight_recursion_factorial(5)
straight_recursion_factorial(5) == 5 * straight_recursion_factorial(4)
straight_recursion_factorial(4) == 4 * straight_recursion_factorial(3)
straight_recursion_factorial(3) == 3 * straight_recursion_factorial(2)
straight_recursion_factorial(2) == 2 * straight_recursion_factorial(1)
straight_recursion_factorial(1) == 1

В последней строчке срабатывает условие if (n <= 1) return(1), потому значение нашей функции равно 1. Это значение подставляется в формулу вычисления факториала от 2, его результат отправляется в формулу для 3 и так далее до 10. Условие для вычисления результата функции от единицы (и меньше) называется выходом из рекурсии. Без него она была бы бесконечной и никогда не разрешилась бы.

Тоже самое, но с подстановкой значений (β-редукция для следующего чтения)

straight_recursion_factorial(10) == 10 * 9 * 8 * 7 * 6 * 5 * 4 * 3 * 2 * 1
straight_recursion_factorial(9) == 9 * 8 * 7 * 6 * 5 * 4 * 3 * 2 * 1
straight_recursion_factorial(8) == 8 * 7 * 6 * 5 * 4 * 3 * 2 * 1
straight_recursion_factorial(7) == 7 * 6 * 5 * 4 * 3 * 2 * 1
straight_recursion_factorial(6) == 6 * 5 * 4 * 3 * 2 * 1
straight_recursion_factorial(5) == 5 * 4 * 3 * 2 * 1
straight_recursion_factorial(4) == 4 * 3 * 2 * 1
straight_recursion_factorial(3) == 3 * 2 * 1
straight_recursion_factorial(2) == 2 * 1
straight_recursion_factorial(1) == 1

Здесь сразу видно слабое место такой рекурсии: с увеличением n растёт и количество необходимых ресурсов для вычисления, потому что все промежуточные состояния до выхода из рекурсии заносятся в стек вызовов. И он вполне может переполниться

> straight_recursion_factorial(10000)
Error: C stack usage  7954120 is too close to the limit

Для оптимизации рекурсии есть вариант написать "хвостовую рекурсию". Его главное отличие - возвращается последний вызов рекурсивной функции, а не первый. Что может заметно ускорить выполнение и затребовать меньше памяти. А в функциональных языках такая рекурсия компилятором или интерпретатором преобразуется в итерацию, что избавляет от проблемы переполненного стека вызовов. К сожалению, R так не умеет, хотя это не означает, что не надо пытаться оптимизирвоать свою рекурсивную функцию.

Для хвостовой рекурсии характерно наличие некоего "аккумулятора", который вбирает в себя результаты вызовов, чтобы на последнем вызове стать возвращаемым значением. Приведу пример с тем же факториалом:

tail_recursion_factorial <- function(n, total = 1) {
  if (n <= 1) return(total)
  tail_recursion_factorial(n - 1, total = n * total)
}

Попробуем вызвать эту функцию и применить редукцию.

tail_recursion_factorial(n = 5, total = 1) == 
	tail_recursion_factorial(n = 4, total = 5 * 1)
tail_recursion_factorial(n = 4, total = 5 * 1) == 
	tail_recursion_factorial(n = 3, total = 4 * 5 * 1)
tail_recursion_factorial(n = 3, total = 4 * 5 * 1) == 
	tail_recursion_factorial(n = 2, total = 3 * 4 * 5 * 1)
	tail_recursion_factorial(n = 2, total = 3 * 4 * 5 * 1) == tail_recursion_factorial(n = 1, total = 2 * 3 * 4 * 5 * 1)
tail_recursion_factorial(n = 1, total = 2 * 3 * 4 * 5 * 1) == 
	2 * 3 * 4 * 5 * 1

Здесь мы видим, что при каждом вызове значение total умножалось на соответствующее n и к последнему вызову, когда происходит выход из рекурсии, проходить обратно по всему стеку для вычисления значения не надо, total уже весь посчитан, остаётся его вернуть. Тем не менее интерпретатор должен умень с этим работать и не забивать стек. В R это не так и это можно увидеть на бенчмарках двух факториалов (прямого и хвостового)

Здесь хвостовая рекурсия по времени выполнения даже хуже обычной. Но есть и обратные примеры. Напишем функцию вычисления n-ого элемента последовательности Фибоначчи. Считаем что первый и второй элементы последовательности равны одному.

straight_recursion_fibonacci <- function(n) {
  if (n <= 2) return(1)
  return(
    straight_recursion_fibonacci(n - 1) + straight_recursion_fibonacci(n - 2)
  )
}

tail_recursion_fibonacci <- function(n, prev = 1, prev_prev = 1) {
  if(n <= 2) return(prev)
  tail_recursion_fibonacci(n - 1, prev = prev + prev_prev, prev_prev = prev)
}

А вот здесь разница огромная. Скорость вычисления в хвостовом варианте выше примерно в 3 раза. И это даже без оптимизации интерпретатором.

Рекурсия - сложная концепция, но позволяет писать простой и понятный код. Описать рекурсию можно меметичной фразой "сводим задачу к предыдущей". Хотя вариант с хвостовой рекурсией последовательнсоти Фибоначчи всё равно не самый очевидный.

Однако на практике почти всегда рекурсию можно заменить функциями высшего порядка. Я могу привести только один реальный пример, когда можно использовать рекурсию вместо цикла и невозможно использовать map или reduce. Я говорю о расчёте размера вклада после определённого периода с извлечением фиксированной суммы каждый месяц. Или с депозитом фиксированной суммы каждый месяц.

# рекурсивный вызов
recursive_dep <- function(n, deposit, percent, withdrawal) {
  if(n == 0) return(deposit)
  dep(n - 1, deposit = deposit * (1 + percent / 12) - withdrawal, percent, withdrawal)
}

# рекурсивный вызов
loop_dep <- function(n, deposit, percent, withdrawal) {
  for (i in seq_len(n)) {
    deposit <- deposit * (1 + percent / 12) - withdrawal
  }
  deposit
}

Кстати, рекурсия сходу получается хвостовой (у нас накапливается deposit).

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

Выводы

R не чистый функциональный язык. Он таковым и не задумывался. Однако это язык высокого уровня абстракции для профессионалов по работе с данными и статистиков. У таких специалистов иногда бывают сложности с пониманием устройства памяти и стандартных императивных алгоритмов. И именно для этого в R есть много элементов ФП. Нам буквально говорят: "Используйте декларативный стиль, пишите что нужно сделать, а не как. А интерпретатор позаботится о деталях."

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

В завершение хочу пригласить вас на бесплатный вебинар, который предназначен для всех, кто интересуется использованием R в своей работе с данными. В ходе вебинара вы познакомитесь с тремя популярными средствами разработки и анализа данных, которые помогут стать более продуктивным и эффективным при работе с R: Rstudio, Jupyter, VSCode.

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


  1. mordoorg
    00.00.0000 00:00
    +1

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