number <- 1
if (number > 0) "Положительное число"[1] "Положительное число"
if, else, else ifУсловные конструкции — стандартная часть почти любого языка программирования. R, конечно, — не исключение. Однако и здесь у R есть свои особенности, которые вытекают из ориентированности R на работу с векторами. В самом простом виде условная конструкция в R выглядит так:
if (условие) выражение
Вот так это будет работать на практике:
number <- 1
if (number > 0) "Положительное число"[1] "Положительное число"
Если выражение (expression) содержит больше одной строчки, то они объединяются фигурными скобками. Впрочем, использовать их можно, даже если в выражении всего одна строчка.
number <- 1
if (number > 0) {
"Положительное число"
}[1] "Положительное число"
В рассмотренной нами конструкции происходит проверка на условие. Если условие верно1, то происходит то, что записано в последующем выражении. Если же условие неверно2, то ничего не происходит.
Ключевое слово else позволяет задавать действие на все остальные случаи:
if (условие) выражение else выражение
Работает это так:
number <- -3
if (number > 0) {
"Положительное число"
} else {
"Отрицательное число или ноль"
}[1] "Отрицательное число или ноль"
Иногда нам нужна последовательная проверка на несколько условий. Для этого есть конструкция else if. Вот как выглядит ее применение:
number <- 0
if (number > 0) {
"Положительное число"
} else if (number < 0){
"Отрицательное число"
} else {
"Ноль"
}[1] "Ноль"
Как мы помним, R — язык, в котором векторизация играет большое значение. Но вот незадача — условные конструкции не векторизованы в R! Давайте попробуем применить эти конструкции для вектора значений и посмотрим, что получится.
numbers <- -2:2
if (numbers > 0) {
"Положительное число"
} else if (numbers < 0){
"Отрицательное число"
} else {
"Ноль"
}Error in `if (numbers > 0) ...`:
! the condition has length > 1
Ошибка! Однако если у вас более старая версия R (до 4.2.0, апрель 2022), то вместо ошибки будет учитываться только первое значение вектора условий: остальные будут игнорироваться, при этом будет выводиться предупреждение. Как же посчитать для всего вектора сразу?
forСамый привычный по другим языкам программирования способ пройтись по всем элементам какого-то объекта — перебрать значения с помощью цикла for. В R, конечно, цикл for тоже есть, синтаксис у него похож на синтаксис условных конструкций:
for (переменная in последовательность) выражение
Например, переберем наш вектор numbers (тот самый, -2:2) и напечатаем каждое значение:
for (i in numbers) {
print(i)
}[1] -2
[1] -1
[1] 0
[1] 1
[1] 2
Обратите внимание: если мы хотим вывести в консоль результат операций внутри цикла, нужно эксплицитно использовать функцию print()3.
Теперь вернемся к нашей задаче — применить условную конструкцию к каждому значению вектора numbers, объединив конструкции if/else if/else и цикл for:
for (i in numbers) {
if (i > 0) {
print("Положительное число")
} else if (i < 0) {
print("Отрицательное число")
} else {
print("Ноль")
}
}[1] "Отрицательное число"
[1] "Отрицательное число"
[1] "Ноль"
[1] "Положительное число"
[1] "Положительное число"
Выглядит несколько монструозно, но это работает. Однако монструозность этой конструкции — не единственная проблема. Есть еще вопрос скорости: о циклах for в R часто говорят, что они медленные. На самом деле это верно лишь отчасти — чаще всего низкая скорость связана с тем, как именно написан цикл.
Типичная ошибка — наращивать объект на каждой итерации. Для компьютера это «дорогая» операция: когда объект увеличивается, R обычно не может просто дописать значение в конец — он выделяет под увеличенный объект новый участок памяти и копирует туда все старые значения, а на следующей итерации повторяет всё заново4. Лучше заранее создать объект нужного размера и затем заполнять его, переписывая значения в уже выделенных ячейках.
Здесь есть еще одна тонкость. До сих пор мы перебирали сами элементы вектора (for (i in numbers)). Но чтобы записывать результат в нужную позицию, нам нужны не значения, а их номера — индексы. Получить их помогает уже знакомая нам функция seq_along() (Глава 3.1): она возвращает номера элементов вектора — для нашего numbers это числа от 1 до 5.
Вот так делать не надо — объект наращивается на каждой итерации:
numbers_descriptions <- character(0) # начинаем с пустого вектора
for (i in seq_along(numbers)) {
if (numbers[i] > 0) {
numbers_descriptions <- c(numbers_descriptions, "Положительное число")
} else if (numbers[i] < 0) {
numbers_descriptions <- c(numbers_descriptions, "Отрицательное число")
} else {
numbers_descriptions <- c(numbers_descriptions, "Ноль")
}
}
numbers_descriptions[1] "Отрицательное число" "Отрицательное число" "Ноль"
[4] "Положительное число" "Положительное число"
А вот так писать циклы правильно — объект сразу создается нужного размера:
numbers_descriptions <- character(length(numbers)) # создаем строковый вектор такой же длины, как исходный
for (i in seq_along(numbers)) {
if (numbers[i] > 0) {
numbers_descriptions[i] <- "Положительное число"
} else if (numbers[i] < 0) {
numbers_descriptions[i] <- "Отрицательное число"
} else {
numbers_descriptions[i] <- "Ноль"
}
}
numbers_descriptions[1] "Отрицательное число" "Отрицательное число" "Ноль"
[4] "Положительное число" "Положительное число"
На таких маленьких векторах разницы в скорости вы не заметите, но на объектах большего размера она будет ощутимой. При аккуратном обращении с циклами особых проблем со скоростью не будет — хотя векторизованные функции всё равно будут быстрее.
И это приводит нас к главному: в R цикл for обычно вообще не нужен5! Чаще всего мы работаем с векторами и датафреймами — наборами относительно независимых наблюдений, и операции над ними могут выполняться независимо друг от друга. Скажем, чтобы перевести массу каждого испытуемого из фунтов в килограммы, цикл не нужен: формула для всех одна и та же, и достаточно применить ее ко всему вектору сразу (это и есть векторизация, см. Глава 3.3). Конечно, внутри это всё равно будет работать через циклы — но для многих базовых функций, написанных на более низкоуровневых языках вроде C и C++, эти циклы выполняются гораздо быстрее, чем циклы, написанные на R.
Конечно, кто-то должен писать циклы
for. Но это не обязательно должны быть вы.
Даже когда расчет для одной строчки зависит от предыдущих, часто есть готовая векторизованная функция и на этот случай — например, cumsum() для накопительной суммы:
cumsum(1:10) [1] 1 3 6 10 15 21 28 36 45 55
Если же подходящей векторизованной функции нет, на помощь приходит семейство функций apply() (см. Глава 8.6).
ifelse() и dplyr::case_when()Из-за того, что конструкция if/else/else if не векторизованная, она редко используется непосредственно в операциях с данными, обычно она используется при написании функций (Глава 8.1) и разработке пакетов.
Альтернативой сочетанию условных конструкций и циклов for является использование встроенной функции ifelse(). Функция ifelse() принимает три аргумента:
test = — условие (т.е. просто логический вектор, состоящий из TRUE и FALSE),
yes = — что выдавать в случае TRUE,
no = — что выдавать в случае FALSE.
На выходе получается вектор такой же длины, как и изначальный логический вектор (условие). Это очень похоже на ЕСЛИ() в Microsoft Excel.
ifelse(numbers > 0, "Положительное число", "Отрицательное число или ноль")[1] "Отрицательное число или ноль" "Отрицательное число или ноль"
[3] "Отрицательное число или ноль" "Положительное число"
[5] "Положительное число"
Пакеты {dplyr} и {data.table} предоставляют более быстрые и более строгие альтернативы для базовой функции ifelse() с аналогичным синтаксисом:
dplyr::if_else(numbers > 0, "Положительное число", "Отрицательное число или ноль")[1] "Отрицательное число или ноль" "Отрицательное число или ноль"
[3] "Отрицательное число или ноль" "Положительное число"
[5] "Положительное число"
data.table::fifelse(numbers > 0, "Положительное число", "Отрицательное число или ноль")[1] "Отрицательное число или ноль" "Отрицательное число или ноль"
[3] "Отрицательное число или ноль" "Положительное число"
[5] "Положительное число"
Если вы пользуетесь одним из этих пакетов (о них пойдет речь далее — см. Глава 9), то я советую пользоваться соответствующей функцией вместо базовой функции ifelse().
У ifelse() тоже есть недостаток: он не может включать в себя дополнительных условий по типу else if. В простых ситуациях можно вставлять ifelse() внутри ifelse():
ifelse(numbers > 0,
"Положительное число",
ifelse(numbers < 0, "Отрицательное число", "Ноль"))[1] "Отрицательное число" "Отрицательное число" "Ноль"
[4] "Положительное число" "Положительное число"
Но что если условий много? Бесконечные ifelse() внутри ifelse() — это громоздко и нечитаемо. Достаточно симпатичное решение есть в пакете {dplyr} — функция case_when(). В качестве аргументов она принимает формулы — специальный класс объектов R (см. Глава 4.6), которые легко узнать по знаку тильды ~:
dplyr::case_when(
numbers > 0 ~ "Положительное число",
numbers < 0 ~ "Отрицательное число",
numbers == 0 ~ "Ноль")[1] "Отрицательное число" "Отрицательное число" "Ноль"
[4] "Положительное число" "Положительное число"
Функция case_when() работает по той же логике, что и if/else/else if конструкция: сначала идет проверка на первое условие (как первое if в конструкции if/else/else if ). Если проверка проходит (то есть в условии получается TRUE), то соответствующее значение возвращается, а остальные условия не проверяются. Если же первое условие не выполняется, то идет проверка на следующее условие (аналог else if). Если же и оно не выполняется, то идет проверка на следующее (следующее else if), пока проверка не пройдет до последнего условия. Можно поставить значение по умолчанию с помощью параметра .default =, которое будет возвращаться, если все проверки выдали FALSE.
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"
) Важный момент: если ifelse() возвращает NA на NA в условии, что обычно нас устраивает (у нас нет данных, что у нас на входе, следовательно, не знаем, что на выходе), то case_when() такого не делает. NA в условии считается как FALSE, поэтому нужно дополнительно обрабатывать условие для него. Чтобы на место NA поставить NA, нужно записать вот так:
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} тоже есть свой (более быстрый) аналог case_when() — функция fcase(). Синтаксис отличается только тем, что вместо формул используются простые запятые. То есть первый аргумент — условие, второй — значение, которое возвращается при верности первого аргумента, третий аргумент — условие, четвертый — возвращаемое значение при верности третьего аргумента и т.д.
data.table::fcase(
numbers > 0, "Положительное число",
numbers < 0, "Отрицательное число",
numbers == 0, "Ноль")[1] "Отрицательное число" "Отрицательное число" "Ноль"
[4] "Положительное число" "Положительное число"
Задача создания вектора или колонки по множественным условиям из другой колонки плавно перетекает в задачу объединения двух датафреймов по единому ключу, и такое решение может оказаться наиболее быстрым (см. Глава 11.1.2).
В принципе, необязательно внутри должна быть проверка условий, достаточно просто значения TRUE.↩︎
Аналогично, достаточно просто значения FALSE.↩︎
Когда вы просто вводите команду в консоли, R неявно вызывает для результата функцию print() — поэтому значение и появляется на экране. Внутри циклов и функций такого автоматического вызова нет, так что print() приходится писать явно.↩︎
Эта классическая ошибка вынесена во «второй круг ада» в эссе Патрика Бёрнса «The R Inferno» (Circle 2, «Growing Objects»).↩︎
Циклы while и repeat, оставшиеся за пределами этой главы, в анализе данных встречаются еще реже, и всё сказанное относится к ним в той же мере.↩︎
Эта идея раскроется полнее, когда мы дойдем до tidyverse и пайпов (Глава 10, Глава 10.4).↩︎
В более свежих версиях пакета {dplyr} разработчики отказались от этой “строгости”, поэтому NA все-таки будут приводиться к нужному типу. Однако data.table::fifelse() остался строгим.↩︎