-
Notifications
You must be signed in to change notification settings - Fork 7
/
050-functional.qmd
290 lines (199 loc) · 27.2 KB
/
050-functional.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# Функциональное программирование в R {#sec-functional}
## Создание функций {#sec-create_fun}
Поздравляю, сейчас мы выйдем на качественно новый уровень владения R. Вместо того, чтобы пользоваться теми функциями, которые уже написали за нас, мы можем сами создавать свои функции! В этом нет ничего сложного.
Синтаксис создания функции внешне похож на создание циклов или условных конструкций. Мы пишем ключевое слово `function`, в круглых скобках обозначаем переменные, с которыми собираемся что-то делать. Внутри фигурных скобок пишем выражения, которые будут выполняться при запуске функции. У функции есть свое собственное **окружение *(environment)*** --- место, где хранятся переменные. Именно те объекты, которые мы передаем в скобочках, и будут в окружении, так же как и "обычные" переменные для нас в глобальном окружении. Это означает, что функция будет искать переменные в первую очередь среди объектов, которые переданы в круглых скобочках. С ними функция и будет работать. На выходе функция выдаст то, что вычисляется внутри функции `return()`. Если `return()` появляется в теле функции несколько раз, то до результат будет возвращаться из той функции `return()`, до которой выполнение дошло первым.
```{r}
pow <- function(x, p) {
power <- x ^ p
return(power)
}
pow(3, 2)
```
Если функция проработала до конца, а функция `return()` так и не встретилась, то возвращается последнее посчитанное значение.
```{r}
pow <- function(x, p) {
x ^ p
}
pow(3, 2)
```
Если в последней строчке будет присвоение, то функция ничего не вернет обратно. Это очень распространенная ошибка: функция вроде бы работает правильно, но ничего не возвращает. Нужно писать так, как будто бы в последней строчке результат выполнения выводится в консоль.
```{r}
pow <- function(x, p) {
power <- x ^ p #Функция ничего не вернет, потому что в последней строчке присвоение!
}
pow(3, 2) #ничего не возвращается из функции
```
Если функция небольшая, то ее можно записать в одну строчку без фигурных скобок.
```{r}
pow <- function(x, p) x ^ p
pow(3, 2)
```
::: callout-warning
## *Для продвинутых:* `{}` -- блок инструкций
Вообще, фигурные скобки используются для того, чтобы выполнить серию команд, но вернуть только результат выполнения последней команды. Такой набор команд называется **блоком инструкций *(statements block)*.** Это можно использовать, чтобы не создавать лишних временных переменных в глобальном окружении.
:::
Мы можем оставить в функции параметры по умолчанию.
```{r}
pow <- function(x, p = 2) x ^ p
pow(3)
pow(3, 3)
```
::: callout-warning
## *Для продвинутых:* ленивые вычисления
В R работают **ленивые вычисления (lazy evaluations)**. Это означает, что параметры функций будут только когда они понадобятся, а не заранее. R будет как самый ленивый прокрастинатор откладывать чтение данных, пока они не понадобятся в вычислениях. Это приводит к тому, что если параметр никак не задан, то обнаружится это только при его непосредственном использовании. Например, эта функция не будет выдавать ошибку, если мы не зададим параметр `we_will_not_use_this_parameter =`, потому что он нигде не используется в расчетах.
```{r}
pow <- function(x, p = 2, we_will_not_use_this_parameter) x ^ p
pow(x = 3)
```
:::
## Проверка на адекватность {#sec-sanity_check}
Лучший способ не бояться ошибок и предупреждений --- научиться прописывать их самостоятельно в собственных функциях. Это позволит понять, что за текстом предупреждений и ошибок, которые у вас возникают, стоит забота разработчиков о пользователях, которые хотят максимально обезопасить нас от наших непродуманных действий.
Хорошо написанные функции не только выдают правильный результат на все возможные адекватные данные на входе, но и не дают получить правдоподобные результаты при неадекватных входных данных. Как вы уже знаете, если на входе у вас имеются пропущенные значения, то многие функции будут в ответ тоже выдавать пропущенные значения. И это вполне осознанное решение, которое позволяет избегать ситуаций вроде той, когда [около одной пятой научных статей по генетике содержало ошибки в приложенных данных](https://genomebiology.biomedcentral.com/articles/10.1186/s13059-016-1044-7) и замечать пропущенные значения на ранней стадии. Кроме того, можно проводить **проверки на адекватность входящих данных *(sanity check).***
Разберем **проверку на адекватность входящих** данных на примере самодельной функции `imt()`, которая выдает индекс массы тела, если на входе задать вес (аргумент `weight =`) в килограммах и рост (аргумент `height =`) в метрах.
```{r}
imt <- function(weight, height) weight / height ^ 2
```
Проверим, что функция работает верно:
```{r}
w <- c(60, 80, 120)
h <- c(1.6, 1.7, 1.8)
imt(weight = w, height = h)
```
Очень легко перепутать и написать рост в сантиметрах. Было бы здорово предупредить об этом пользователя, показав ему предупреждающее сообщение, если рост больше, чем, например, 3. Это можно сделать с помощью функции `warning()`.
```{r}
imt <- function(weight, height) {
if (any(height > 3)) warning("Рост в аргументе height больше 3: возможно, указан рост в сантиметрах, а не в метрах\n")
weight / height ^ 2
}
imt(78, 167)
```
В некоторых случаях ответ будет совершенно точно некорректным, хотя функция все посчитает и выдаст ответ, как будто так и надо. Например, если какой-то из аргументов функции `imt()` будет меньше или равен 0. В этом случае нужно прописать проверку на это условие. Если это действительно так, то можно поступить еще строже: выдать пользователю ошибку.
```{r, error=TRUE}
imt <- function(weight, height) {
if (any(weight <= 0 | height <= 0)) stop("Индекс массы тела не может быть посчитан для отрицательных значений")
if (any(height > 3)) warning("Рост в аргументе height больше 3: возможно, указан рост в сантиметрах, а не в метрах\n")
weight / height ^ 2
}
imt(-78, 167)
```
Когда вы попробуете самостоятельно прописывать предупреждения и ошибки в функциях, то быстро поймете, что ошибки - это вовсе не обязательно результат того, что где-то что-то сломалось и нужно паниковать. Совсем даже наоборот, прописанная ошибка - чья-то забота о пользователях, которых пытаются максимально проинформировать о том, что и почему пошло не так.
Это естественно в начале работы с R (и вообще с программированием) избегать ошибок и пугаться их. Конечно, в самом начале обучения большая часть из них остается непонятной. Но постарайтесь понять текст ошибки, вспомнить в каких случаях у вас возникала похожая ошибка. Очень часто этого оказывается достаточно чтобы понять причину ошибки даже если вы только-только начали изучать R.
Ну а в дальнейшем я советую ознакомиться со [средствами отладки кода в R](https://adv-r.hadley.nz/debugging.html) для того, чтобы научиться справляться с ошибками в своем коде на более продвинутом уровне.
## Когда и зачем создавать функции? {#sec-why_functions}
Когда стоит создавать функции? Существует ["правило трех"](https://en.wikipedia.org/wiki/Rule_of_three_(computer_programming)) --- если у вас есть три куска очень похожего кода, то самое время превратить код в функцию. Это очень условное правило, но, действительно, стоит избегать копипастинга в коде. В этом случае очень легко ошибиться, а сам код становится нечитаемым.
Есть и другой подход к созданию функций: их стоит создавать не столько для того, чтобы использовать тот же код снова, сколько для абстрагирования от того, что происходит в отдельных строчках кода. Если несколько строчек кода были написаны для того, чтобы решить одну задачу, которой можно дать понятное название (например, подсчет какой-то особенной метрики, для которой нет готовой функции в R), то этот код стоит обернуть в функцию. Если функция работает корректно, то теперь не нужно думать над тем, что происходит внутри нее. Вы ее можете мысленно представить как операцию, которая имеет определенный вход и выход --- как и встроенные функции в R.
Отсюда следует важный вывод, что хорошее название для функции --- это очень важно. Очень, очень, очень важно.
## Функции как объекты первого порядка {#sec-functions_objects}
Ранее мы убедились, что арифметические операторы --- это тоже функции. На самом деле, практически все в R --- это функции. Даже `function` --- это функция `function()`. Даже скобочки `(`, `{` --- это функции!
А сами функции --- это объекты первого порядка в R. Это означает, что с функциями вы можете делать практически все то же самое, что и с другими объектами в R (векторами, датафреймами и т.д.). Небольшой пример, который может взорвать ваш мозг:
```{r}
list(mean, min, `{`)
```
Мы можем создать список из функций! Зачем --- это другой вопрос, но ведь можем же! Например, создавать списки из функций может быть удобным для продвинутых операций в `across()` в пакете `{dplyr}` (см. @sec-tidy_across).
Еще можно создавать функции внутри функций[^050-functional-1], использовать функции в качестве аргументов функций, сохранять функции как переменные. Пожалуй, самое важное из этого всего - это то, что функция может быть аргументом в функции. Не просто название функции как строковая переменная, не результат выполнения функции, а именно сама функция как объект! Это лежит в основе использования семейства функций `apply()`, о которых пойдет речь далее, и многих фишек *tidyverse.*
[^050-functional-1]: Функция, которая создает другие функции, называется **фабрикой функций**.
::: callout-tip
## *Полезное:* а как в Python?
В Python дело обстоит похожим образом: функции там тоже являются объектами первого порядка, поэтому все эти фишки функционального программирования (с поправкой на синтаксис, конечно) будут работать и там.
:::
## Семейство функций `apply()` {#sec-apply_f}
### Применение `apply()` для матриц {#sec-apply}
Семейство? Да, их целое множество: `apply()`, `lapply()`,`sapply()`, `vapply()`,`tapply()`,`mapply()`, `rapply()`... Ладно, не пугайтесь, всех их знать не придется. Обычно достаточно первых двух-трех. Проще всего пояснить как они работают на простой матрице с числами:
```{r}
A <- matrix(1:12, 3, 4)
A
```
::: callout-important
## *Осторожно:* `apply()` не работает с датафреймами
Функция `apply()` предназначена для работы с матрицами (или многомерными массивами). Если вы скормите функции `apply()` датафрейм, то этот датафрейм будет сначала превращен в матрицу. Главное отличие матрицы от датафрейма в том, что в матрице все значения одного типа, поэтому будьте готовы, что сработает имплицитное приведение к общему типу данных. Например, если среди колонок датафрейма есть хотя бы одна строковая колонка, то все колонки станут строковыми.
:::
Теперь представим, что нам нужно посчитать что-нибудь (например, сумму) по каждой из строк. С помощью функции `apply()` вы можете в буквальном смысле "применить" функцию к матрице или датафрейму. Синтаксис такой: `apply(X, MARGIN, FUN, ...)`, где `X` --- данные, `MARGIN` это `1` (для строк), `2` (для колонок), `c(1,2)` для строк и колонок (т.е. для каждого элемента по отдельности), а `FUN` --- это функция, которую вы хотите применить! `apply()` будет брать строки/колонки из `X` в качестве первого аргумента для функции.
![Визуальное представление функции apply(): выбираем матрицу, задаем способ итерации (по строкам или столбцам, выбираем функцию, которая будет применяться на каждом элементе.](images/050-functional_apply_function.png)
::: callout-important
Заметьте, мы вставляем функцию без скобок и кавычек как аргумент в функцию. Это как раз тот случай, когда аргументом в функции выступает сама функция, а не ее название или результат ее выполнения.
:::
Давайте разберем на примере:
```{r}
apply(A, 1, sum) #сумма по каждой строчке
apply(A, 2, sum) #сумма по каждой колонке
apply(A, c(1,2), sum) #кхм... сумма каждого элемента
```
::: callout-tip
## *Полезное:* специальные функции для операций над всеми строками/столбцами
Конкретно для подсчета сумм и средних по столбцам и строкам в R есть функции `colSums()`, `rowSums()`, `colMeans()` и `rowMeans()`, которые можно использовать как альтернативы `apply()` в данном случае.
:::
Если же мы хотим прописать дополнительные аргументы для функции, то их можно перечислить через запятую после функции:
```{r}
A[2, 2] <- NA
A
apply(A, 1, sum)
apply(A, 1, sum, na.rm = TRUE)
```
### Анонимные функции {#sec-anon_f}
Что делать, если мы хотим сделать что-то более сложное, чем просто применить одну функцию? А если функция принимает не первым, а вторым аргументом данные из матрицы? В этом случае нам помогут **анонимные функции *(anonymous function)****.*
Анонимные функции - это функции, которые будут использоваться один раз и без названия.
::: callout-tip
## *Полезное:* а как в Python?
Питонистам знакомо понятие **лямбда-функций**. Да, это то же самое.
:::
Например, мы можем посчитать общее количество знаков по строкам и столбцам без называния этой функции:
```{r}
B <- matrix(c("Всем", "привет", "Я", "строковая", "матрица", "и", "такое", "тоже", "бывает"), nrow = 3)
apply(B, 1, function(x) sum(nchar(x)))
apply(B, 2, function(x) sum(nchar(x)))
```
Начиная с R 4.1.0 (май 2021) можно использовать сокращенный вариант написания анонимных функций с `\` вместо ключевого слова `function`:
```{r}
apply(B, 1, \(x) sum(nchar(x)))
apply(B, 2, \(x) sum(nchar(x)))
```
::: callout-tip
## *Полезное:* аргументы анонимной функции
Как и в случае с обычной функцией, в качестве `x` выступает объект, с которым мы хотим что-то сделать, а дальше следует функция, которую мы собираемся применить к `х`. Можно использовать не `х`, а что угодно, как и в обычных функциях:
```{r}
apply(B, 1, function(whatevername) sum(nchar(whatevername)))
```
:::
### Другие функции семейства `apply()` {#sec-apply_other}
ОК, с `apply()` разобрались. А что с остальными? Некоторые из функций семейства `*apply()` еще проще и не требуют индексов, например, `lapply()` (для применения к каждому элементу списка) и `sapply()` - упрощенная версия `lapply()`, которая пытается по возможности "упростить" результат до вектора или матрицы.
```{r}
some_list <- list(some = 1:10, list = letters)
lapply(some_list, length)
sapply(some_list, length)
```
::: callout-note
## *Осторожно:* такой непредсказуемый `sapply()`
Достаточно сложно предсказать, в каких именно случаях будет произведено упрощение, а в каких нет. Поэтому `sapply()` удобен в исследовании данных, но использовать эту функцию в скриптах не очень рекомендуется. Один из вариантов решения этой проблемы --- это функция `vapply()`, которая позволяет управлять результатом `lapply()`, но гораздо более красиво эта проблема решена в пакете `{purrr}` (см. @sec-purrr ).
:::
Использование `sapply()` на векторе приводит к тем же результатам, что и просто применить векторизованную функцию обычным способом.
```{r}
sapply(1:10, sqrt)
sqrt(1:10)
```
Зачем вообще тогда нужен `sapply()`, если мы можем просто применить векторизованную функцию? Ключевое слово здесь *векторизованная* функция. Если функция не векторизована, то `sapply()` становится удобным вариантом для того, чтобы избежать итерирования с помощью циклов `for`.
::: callout-warning
## *Для продвинутых:* `Vectorize()`
Еще одна альтернатива - это векторизация невекторизованной функции с помощью `Vectorize()`. Эта функция просто оборачивает функцию одним из вариантов `apply()`. Обратите внимание: функция `Vectorize()` в качестве аргумента принимает функцию и возвращает тоже функцию!
:::
Можно применять функции `lapply()` и `sapply()` на датафреймах. Поскольку фактически датафрейм - это список из векторов одинаковой длины (см. @sec-df), то итерироваться эти функции будут по колонкам:
```{r}
heroes <- read.csv("https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv",
na.strings = c("-", "-99"))
sapply(heroes, class)
```
Еще одна функция из семейства `apply()` - функция `replicate()` - самый простой способ повторить одну и ту же операцию много раз. Обычно эта функция используется при симуляции данных и моделировании. Например, давайте сделаем выборку из логнормального распределения (подробнее про распределения см. в @sec-distributions):
```{r}
samp <- rlnorm(30)
hist(samp)
```
А теперь давайте сделаем 1000 таких выборок и из каждой возьмем среднее:
```{r}
sampdist <- replicate(1000, mean(rlnorm(30)))
hist(sampdist)
```
Про функции для генерации случайных чисел и про визуализацию будет в следующих главах: @sec-distributions и @sec-r_vis соответственно.
::: callout-warning
## *Для продвинутых:* больше `apply()`
Если хотите познакомиться с семейством `apply()` чуточку ближе, то рекомендую [вот этот туториал](https://www.datacamp.com/community/tutorials/r-tutorial-apply-family).
:::
В заключение стоит сказать, что семейство функций `apply()` --- это очень сильное колдунство, но в *tidyverse* оно практически полностью перекрывается функциями из пакета `{purrr}`, за исключением самого `apply()` и некоторых других функций, которые работают с матрицами и массивами (*tidyverse* с ними принципиально не дружит). Впрочем, если вы поняли логику `apply()`, то при желании вы легко сможете переключиться на альтернативы из пакета `{purrr}` (см. @sec-purrr).