forked from Pozdniakov/tidy_stats
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy path040-if_for.qmd
251 lines (182 loc) · 19.3 KB
/
040-if_for.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
# Условные конструкции и циклы {#sec-loops_conditions}
## Выражения `if`, `else`, `else if` {#sec-sec-if}
Стандартная часть практически любого языка программирования --- условные конструкции. R не исключение. Однако и здесь есть свои особенности. Начнем с самого простого варианта с одним условием. Выглядеть условная конcтрукция будет вот так:
```
if (условие) выражение
```
Вот так это будет работать на практике:
```{r}
number <- 1
if (number > 0) "Положительное число"
```
Если выражение (expression) содержит больше одной строчки, то они объединяются фигурными скобками. Впрочем, использовать их можно, даже если строчка всего в выражении всего одна.
```{r}
number <- 1
if (number > 0) {
"Положительное число"
}
```
В рассмотренной нами конструкции происходит проверка на условие. Если условие верно[^040-if_for-1], то происходит то, что записано в последующем выражении. Если же условие неверно[^040-if_for-2], то ничего не происходит.
[^040-if_for-1]: В принципе, необязательно внутри должна быть проверка условий, достаточно просто значения `TRUE`.
[^040-if_for-2]: Аналогично, достаточно просто значения `FALSE`.
Оператор `else` позволяет задавать действие на все остальные случаи:
```
if (условие) выражение else выражение
```
Работает это так:
```{r}
number <- -3
if (number > 0) {
"Положительное число"
} else {
"Отрицательное число или ноль"
}
```
Иногда нам нужна последовательная проверка на несколько условий. Для этого есть оператор `else if`. Вот как выглядит ее применение:
```{r}
number <- 0
if (number > 0) {
"Положительное число"
} else if (number < 0){
"Отрицательное число"
} else {
"Ноль"
}
```
Как мы помним, R --- язык, в котором векторизация играет большое значение. Но вот незадача --- условные конструкции не векторизованы в R! Давайте попробуем применить эти конструкции для вектора значений и посмотрим, что получится.
```{r}
#| error: true
numbers <- -2:2
if (numbers > 0) {
"Положительное число"
} else if (numbers < 0){
"Отрицательное число"
} else {
"Ноль"
}
```
Ошибка! Однако если у вас более старая версия R (до 4.2.0, апрель 2022), то вместо ошибки будет учитываться только первое значение вектора условий: остальные будут игнорироваться, при этом будет выводиться предупреждение. Как же посчитать для всего вектора сразу?
::: callout-tip
## *Полезное:* применение условных конструкций
Невекторизованная конструкция *if/else/else if* неудобна при работе с данными, ее практически не используют для обработки данных. В основном она применяется при написании функций, чтобы проверить конкретное значение параметра или адекватность данных на входе (см. @sec-functional).
:::
## Циклы `for` {#sec-for}
Во-первых, можно использовать `for`. Синтаксис у `for` похож на синтаксис условных конструкций.
```
for(переменная in последовательность) выражение
```
Теперь мы можем объединить условные конструкции и `for`. Немножко монструозно, но это работает:
```{r}
for (i in numbers) {
if (i > 0) {
print("Положительное число")
} else if (i < 0) {
print("Отрицательное число")
} else {
print("Ноль")
}
}
```
::: callout-important
## *Осторожно:* `print()`
Чтобы выводить в консоль результат вычислений внутри `for`, нужно использовать `print()`.
:::
Здесь стоит отметить, что `for` используется в R относительно редко. В подавляющем числе ситуаций использование `for` можно избежать. Обычно мы работаем в R с векторами или датафреймами, которые представляют собой множество относительно независимых наблюдений. Если мы хотим провести какие-нибудь операции с этими наблюдениями, то они обычно могут быть выполнены параллельно. Скажем, вы хотите для каждого испытуемого пересчитать его массу из фунтов в килограммы. Этот пересчет осуществляется по одинаковой формуле для каждого испытуемого. Эта формула не изменится из-за того, что какой-то испытуемый слишком большой или слишком маленький - для следующего испытуемого формула будет прежняя. Если Вы встречаете подобную задачу (где функцию можно применить независимо для всех значений), то без цикла `for` вполне можно обойтись.
Даже во многих случаях, где расчеты для одной строчки зависят от расчетов предыдущих строчек, можно обойтись без `for` векторизованными функциями, например, `cumsum()` для подсчета кумулятивной суммы.
```{r}
cumsum(1:10)
```
Если же нет подходящей векторизованной функции, то можно воспользоваться семейством функций `apply()` (см. @sec-apply_f).
::: callout-tip
## *Полезное:* зачем циклы? прост
После этих объяснений кому-то может показаться странным, что я вообще упоминаю про эти циклы. Но для кого-то циклы `for` настолько привычны, что их полное отсутствие в курсе может показаться еще более странным. Поэтому лучше от меня, чем на улице.
:::
Зачем вообще избегать конструкций `for`? Некоторые говорят, что они слишком медленные, и частично это верно, если мы сравниваем с векторизованными функциями, которые написаны на более низкоуровневых языках. Но в большинстве случаев низкая скорость `for` связана с неправильным использованием этой конструкции. Например, стоит избегать ситуации, когда на каждой итерации `for` какой-то объект (вектор, список, что угодно) изменяется в размере. Лучше будет создать заранее объект нужного размера, который затем будет наполняться значениями:
```{r}
numbers_descriptions <- character(length(numbers)) #создаем строковый вектор с такой же длиной, как и исходный вектор
for (i in 1:length(numbers)) {
if (numbers[i] > 0) {
numbers_descriptions[i] <- "Положительное число"
} else if (numbers[i] < 0) {
numbers_descriptions[i] <- "Отрицательное число"
} else {
numbers_descriptions[i] <- "Ноль"
}
}
numbers_descriptions
```
В общем, при правильном обращении с `for` особых проблем со скоростью не будет, хотя векторизованные функции и будут быстрее. Но все равно это будет громоздкая конструкция, в которой легко ошибиться, и которую, скорее всего, можно заменить одной короткой строчкой. Кроме того, без конструкции `for` код обычно легко превратить в набор функций, последовательно применяющихся к данным, что мы будем по максимуму использовать, работая в tidyverse и применяя пайпы (см. @sec-pipe).
## Векторизованные условные конструкции: функции `ifelse()` и `dplyr::case_when()` {#sec-ifelse}
Из-за того, что конструкция *if/else/else if* не векторизованная, она редко используется непосредственно в операциях с данными, обычно она используется при написании функций (@sec-create_fun) и разработке пакетов.
Альтернативой сочетанию условных конструкций и циклов `for` является использование встроенной функции `ifelse()`. Функция `ifelse()` принимает три аргумента:
1. `test =` -- условие (т.е. просто логический вектор, состоящий из `TRUE` и `FALSE`),
2. `yes =` -- что выдавать в случае `TRUE`,
3. `no =` -- что выдавать в случае `FALSE`.
На выходе получается вектор такой же длины, как и изначальный логический вектор (условие). Это очень похоже на `ЕСЛИ()` в *Microsoft Excel.*
```{r}
ifelse(numbers > 0, "Положительное число", "Отрицательное число или ноль")
```
::: callout-tip
## *Полезное:* иногда `ifelse()` излишен
Периодически я встречаю у студентов строчку вроде такой: `ifelse(условие, TRUE, FALSE)`. Эта конструкция избыточна, т.к. получается, что логический вектор из `TRUE` и `FALSE` превращается в абсолютно такой же вектор из `TRUE` и `FALSE` на тех же самых местах. Выходит, что ничего не меняется!
:::
::: callout-important
## *Осторожно:* `NA` в `ifelse()` превращается в `NA`
`NA` в условии в `ifelse()` возвращает `NA`. Обычно это именно то поведение, которое вы ожидаете: если в исходном векторе есть неопределенность, то и на выходе должна остаться неопределенность на соответствующих позициях.
:::
Пакеты `{dplyr}` и `{data.table}` предоставляют более быстрые и более строгие альтернативы для базовой функции `ifelse()` с аналогичным синтаксисом:
```{r}
dplyr::if_else(numbers > 0, "Положительное число", "Отрицательное число или ноль")
data.table::fifelse(numbers > 0, "Положительное число", "Отрицательное число или ноль")
```
Если вы пользуетесь одним из этих пакетов (о них пойдет речь далее --- см. @sec-beyond_base_r, то я советую пользоваться соотвествующей функцией вместо базового `ifelse()`.
::: callout-important
## *Осторожно:* возвращение `NA` может привести к ошибкам
Обе функции будут избегать скрытого приведения типов (см. @sec-coercion) и намеренно выдавать ошибку при использовании разных типов данных в параметрах `yes =` и `no =`[^040-if_for-3]. Помните, что `NA` по умолчанию --- это логический тип данных, поэтому в этих функциях нужно использовать `NA` соответствующего типа `NA_character_`, `NA_integer_`, `NA_real_`, `NA_complex_` (см. @sec-na).
:::
[^040-if_for-3]: В более свежих версиях пакета `{dplyr}` разработчики отказались от этой "строгости", поэтому `NA` все-таки будут приводиться к нужному типу. Однако `data.table::fifelse()` остался строгим.
У `ifelse()` тоже есть недостаток: он не может включать в себя дополнительных условий по типу `else if`. В простых ситуациях можно вставлять `ifelse()` внутри `ifelse()`:
```{r}
ifelse(numbers > 0,
"Положительное число",
ifelse(numbers < 0, "Отрицательное число", "Ноль"))
```
Достаточно симпатичное решение есть в пакете `dplyr` --- функция `case_when()`, которая работает с использованием формулы:
```{r}
dplyr::case_when(
numbers > 0 ~ "Положительное число",
numbers < 0 ~ "Отрицательное число",
numbers == 0 ~ "Ноль")
```
Функция `case_when()` работает по той же логике, что и *if/else/else if* конструкция: сначала идет проверка на первое условие (как первое *if* в конструкции *if/else/else if* ). Если проверка проходит (то есть в условии получается `TRUE`), то соответствующее значение возвращается, а остальные условия не проверяются. Если же первое условие не выполняется, то идет проверка на следующее условие (аналог *else if*). Если же и оно не выполняется, то идет проверка на следующее (следующее *else if*), пока проверка не пройдет до последнего условия. Можно поставить значение по умолчанию с помощью параметра `.default =`, которое будет возвращаться, если все проверки выдали `FALSE`.
```{r}
heroes <- read.csv("https://raw.githubusercontent.com/Pozdniakov/tidy_stats/master/data/heroes_information.csv", na.strings = c("NA", "-", "-99"))
heroes$weight_group <- dplyr::case_when(
heroes$Weight > 200 ~ "overweight", # "if"
heroes$Weight > 120 ~ "somewhat overweight", # "else if"
heroes$Weight < 50 ~ "underweight", # next "else if"
.default = "typical weight" # final "else"
)
```
::: callout-important
## Осторожно: не забывайте про запятые
Будьте внимательны с запятыми! Несмотря на довольно экстравагантный синтаксис, `case_when()` -- это по-прежнему функция, а различные условия, которые вы прописываете внутри этой функции -- это аргументы этой функции.
:::
Важный момент: если `ifelse()` возвращает `NA` на `NA` в условии, что обычно нас устраивает (у нас нет данных, что у нас на входе, следовательно, не знаем, что на выходе), то `case_when()` такого не делает. `NA` в условии считается как `FALSE`, поэтому нужно дополнительно обрабатывать условие для него. Чтобы на место `NA` поставить NA, нужно записать вот так:
```{r}
heroes$weight_group <- dplyr::case_when(
heroes$Weight > 200 ~ "overweight", # "if"
heroes$Weight > 120 ~ "somewhat overweight", # "else if"
heroes$Weight < 50 ~ "underweight", # next "else if"
is.na(heroes$Weight) ~ NA, # one more "else if", maps NA to NA
.default = "typical weight" # final "else"
) # final "else"
```
В `{data.table}` тоже есть свой ([более быстрый](https://themockup.blog/posts/2021-02-13-joins-vs-casewhen-speed-and-memory-tradeoffs/)) аналог `case_when()` --- функция `fcase()`. Синтаксис отличается только тем, что вместо формул используются простые запятые. То есть первый аргумент -- условие, второй -- значение, которое возвращается при верности первого аргумента, третий аргумент -- условие, четвертый -- возвращаемое значение при верности третьего аргумента и т.д.
```{r}
data.table::fcase(
numbers > 0, "Положительное число",
numbers < 0, "Отрицательное число",
numbers == 0, "Ноль")
```
Задача создания вектора или колонки по множественным условиям из другой колонки плавно перетекает в задачу объединения двух датафреймов по единому ключу, и такое решение может оказаться [наиболее быстрым](https://themockup.blog/posts/2021-02-13-joins-vs-casewhen-speed-and-memory-tradeoffs/) (см. @sec-tidy_join).