<- c("в", "мысли", "ему", "то", "вложила", "богиня", "державная", "гера") homer
4 Циклы, условия, функции
В этом уроке мы познакомимся с итерационными конструкциями и функционалами, т.е. такими функциями, которые принимают в качестве аргумента другую функцию. Они нужны для того, чтобы можно было что-то сделать много раз: например, прочитать сразу 100 файлов из директории, построить и сохранить одной командой несколько графиков или сделать множество случайных выборок из большого текстового корпуса.
Общее правило таково: если вы скопировали кусок кода больше трех раз, то самое время задуматься об итерации. Это позволит избежать ошибок при копировании и сделает код более легким и читаемым.
4.1 Векторизованные вычисления
Хорошая новость: многие функции в R уже векторизованы, и если необходимо применить функцию к каждому элементу вектора, в большинстве случаев достаточно просто вызвать функцию. Это называется векторизация.
Например, у нас есть символьный вектор, и мы хотим узнать количество символов в каждом слове.
Для каждого компонента вектора необходимо выполнить одну итерацию цикла, в нашем случае – применить функцию nchar()
. В некоторых языках программирования это делается как-то так:
for(i in homer) print(nchar(i))
[1] 1
[1] 5
[1] 3
[1] 2
[1] 7
[1] 6
[1] 9
[1] 4
Мы написали цикл for
, который считает количество букв для каждого слова в векторе. Как видно, все сработало. Но в R это избыточно, потому что nchar()
уже векторизована:
nchar(homer)
[1] 1 5 3 2 7 6 9 4
Это относится не только ко многим встроенным функциям R, но и к даже к операторам. x + 4
в действительности представляет собой +(x, 4)
:
<- c(1.2, 2.51, 3.8)
x
`+`(x, 4)
[1] 5.20 6.51 7.80
Ключевую роль здесь играет переработка данных, о которой мы говорили в первом уроке: короткий вектор повторяется до тех пор, пока его длина не сравняется с длиной более длинного вектора. Как-то так:
\[ \left( \begin{array}{c} 1.2 \\ 2.51 \\ 3.8 \end{array} \right) + \left( \begin{array}{c} 4 \\ 4 \\ 4 \end{array} \right) \]
Лишний цикл может замедлить вычисления. Проверим. Дан вектор x <- c(3, 5, 7, 13)
. Необходимо возвести в квадрат каждое число, а из результата вычесть 100. Выполним двумя способами.
library(tictoc)
<- c(2, 3, 5, 7, 11, 13)
x
# способ первый
tic()
for (i in x) print(i^2 - 100)
[1] -96
[1] -91
[1] -75
[1] -51
[1] 21
[1] 69
toc()
0.002 sec elapsed
# способ второй
tic()
^2 - 100 x
[1] -96 -91 -75 -51 21 69
toc()
0.001 sec elapsed
Один из главных принципов программирования на R гласит, что следует обходиться без циклов, а если это невозможно, то циклы должны быть простыми.
— Нормат Мэтлофф
4.2 Семейство _apply()
Для работы со списками циклы тоже чаще всего избыточны. Снова воспользуемся списком печенек из коллекции rcorpora
.
library(rcorpora)
<- corpora("foods/breads_and_pastries")
my_list
tic()
for (i in 1:length(my_list)) print(length(my_list[[i]]))
[1] 1
[1] 35
[1] 20
toc()
0.001 sec elapsed
Но в базовом R для таких случаев существуют функционалы lapply()
и sapply()
. Они принимают на входе список и функцию и применяют функцию к каждому элементу списка. Получается быстрее:
tic()
lapply(my_list, length)
$description
[1] 1
$breads
[1] 35
$pastries
[1] 20
toc()
0.001 sec elapsed
Функция sapply()
упростит результат до вектора (s
означает “simplify”):
tic()
sapply(my_list, length)
description breads pastries
1 35 20
toc()
0.001 sec elapsed
Поскольку датафрейм – это двумерный аналог списка, то и здесь можно заменить цикл на _apply()
. Сравните.
<- data.frame(author=c("Joe","Jane"), year=c(1801,1901), reprints=c(TRUE,FALSE))
df
## цикл
tic()
for (i in seq_along(df)) {
print(class(df[,i]))
}
[1] "character"
[1] "numeric"
[1] "logical"
toc()
0.002 sec elapsed
## sapply
tic()
sapply(df, class)
author year reprints
"character" "numeric" "logical"
toc()
0 sec elapsed
Есть еще vapply()
, tapply()
и mapply()
, но и про них мы не будем много говорить, потому что все их с успехом заменяет семейство map_()
из пакета purrr
в tidyverse
.
Тем не менее, перед освоением семейства map_()
стоит потренироваться работать с обычными циклами, особенно если вам не приходилось иметь с ними дела (например, на Python). Несмотря на все недостатки, цикл for
интуитивно понятен и часто проще начинать именно с него.
4.3 Синтаксис функций
Функция и код – не одно и то же. Чтобы стать функцией, кусок кода должен получить имя. Но зачем давать имя коду, который и так работает?
Вот три причины, которые приводит Хадли Уикхем:
- у функции есть выразительное имя, которое облегчает понимание кода;
- при изменении требований необходимо обновлять код только в одном месте, а не во многих;
- меньше вероятность случайных ошибок при копировании (например, обновление имени переменной в одном месте, но не в другом)
Writing good functions is a lifetime journey.
— Hadley Wickham
Машине все равно, как вы назовете функцию, но тем, кто будет читать код, не все равно. Имена должны быть информативы (поэтому функция f()
– плохая идея). Также не стоит переписывать уже существующие в R имена!
Далее следует определить формальные аргументы и, при желании, значения по умолчанию. Тело функции пишется в фигурных скобках. В конце кода функции располагается команда return()
; если ее нет, то функция возвращает последнее вычисленное значение (см. здесь о том, когда что предпочесть).
Написание функций – навык, который можно бесконечно совершенствовать. Начать проще всего с обычного кода. Убедившись, что он работает как надо, вы можете упаковать его в функцию.
Напишем функцию, которая будет переводить градусы по Фаренгейту в градусы по Цельсию.
<- function(fahrenheit){
fahrenheit_to_celsius = (fahrenheit - 32) / 1.8
celsius return(round(celsius))
}
fahrenheit_to_celsius(451)
[1] 233
Внутри нашей функции есть переменная celsius
, которую не видно в глобальном окружении. Это локальная переменная. Область ее видимости – тело функции. Когда функция возвращает управление, переменная исчезает. Обратное неверно: глобальные переменные доступны в теле функции.
4.4 Ленивые вычисления
Вычисления в R ленивы, то есть они откладываются до тех пор, пока не понадобится результат. Если вы зададите аргумент, который не нужен в теле функции, ошибки не будет.
<- function(fahrenheit, your_name = "locusclassicus"){
fahrenheit_to_celsius = (fahrenheit - 32) / 1.8
celsius return(round(celsius))
}
fahrenheit_to_celsius(451)
[1] 233
4.5 Условия
Иногда необходимо ограничить выполнение функции неким условием. Короткие условия можно писать в одну строку без фигурных скобок.
<- "Эйяфьятлайокудль"
word
if(nchar(word) > 10) print("много букв")
[1] "много букв"
Более сложные и множественные условия требуют фигурных скобок. Можно сравнить это с условным периодом: протасис (всегда либо TRUE, либо FALSE) в круглых скобках, аподосис в фигурных.
if (nchar(word) > 10) {
print("много букв")
else if (nchar(word) < 5) {
} print("мало букв")
else {
} print("норм букв")
}
[1] "много букв"
Также в R можно использовать специальную функцию:
ifelse(nchar(word) > 10, "много букв", "мало букв")
[1] "много букв"
Прописывая условие, не забывайте, что применение булева оператора к вектору возвращает логический вектор:
<- 1:10
x >= 5 x
[1] FALSE FALSE FALSE FALSE TRUE TRUE TRUE TRUE TRUE TRUE
Такое условие вернет ошибку.
if (x >= 5) print("все сломалось")
Error in if (x >= 5) print("все сломалось"): the condition has length > 1
Можно скорректировать код так:
if (any(x >= 5)) print("все сработало")
[1] "все сработало"
4.6 Условия внутри функций
Функция может принимать произвольное число аргументов. Доработаем наш код:
<- function(x, mode = "f_to_c"){
convert_temperature if(mode == "f_to_c") {
= round((x - 32) / 1.8)
celsius return(paste(celsius, "градусов по Цельсию"))
else if (mode == "c_to_f") {
} = round(x * 1.8 + 32)
fahrenheit return(paste(fahrenheit, "градусов по Фаренгейту"))
}
}
convert_temperature(84)
[1] "29 градусов по Цельсию"
convert_temperature(29, mode = "c_to_f")
[1] "84 градусов по Фаренгейту"
4.7 Сообщения и условия остановки
Часто имеет смысл добавить условие остановки или сообщение, которое будет распечатано в консоль при выполнении.
<- function(x, mode = "f_to_c"){
convert_temperature if(!is.numeric(x)) stop("non-numeric input")
message("Please, wait...")
if(mode == "f_to_c") {
= round((x - 32) / 1.8)
celsius return(paste(celsius, "градусов по Цельсию"))
else if (mode == "c_to_f") {
} = round(x * 1.8 + 32)
fahrenheit return(paste(fahrenheit, "градусов по Фаренгейту"))
}
}
convert_temperature("двадцать пять")
Error in convert_temperature("двадцать пять"): non-numeric input
convert_temperature(78)
Please, wait...
[1] "26 градусов по Цельсию"
4.8 switch()
Слишком много условий в теле функции могут сделать ее нечитаемой. Для таких случаев подойдет switch()
.
<- function(x, mode = "f_to_c"){
convert_temperature if(!is.numeric(x)) stop("wrong input")
switch(mode,
f_to_c = round((x - 32) / 1.8) |>
paste("градусов по Цельсию"),
c_to_f = round(x * 1.8 + 32) |>
paste("градусов по Фаренгейту"),
stop("unknown mode")
)
}
convert_temperature(78, mode = "c_to_k")
Error in convert_temperature(78, mode = "c_to_k"): unknown mode
convert_temperature(78, mode = "f_to_c")
[1] "26 градусов по Цельсию"
4.9 Пакет purrr
По-настоящему мощный инструмент для итераций – это пакет purrr
из семейства tidyverse
. Разработчики предупреждают, что потребуется время, чтобы овладеть этим инструментом (Wickham и Grolemund 2016).
You should never feel bad about using a loop instead of a map function. The map functions are a step up a tower of abstraction, and it can take a long time to get your head around how they work.
— Hadley Wickham & Garrett Grolemund
В семействе функций map_
из этого пакета всего 23 вариации. Вот основные из них:
map()
map_lgl()
map_int()
map_dbl()
map_chr()
Все они принимают на входе данные и функцию (или формулу), которую следует к ним применить, и возвращают результат в том виде, который указан после подчеркивания. Просто map()
вернет список, а map_int()
– целочисленный вектор, и т.д.
4.10 map()
Воспользуемся возможностями purrr
, чтобы исследовать датасет starwars
из пакета dplyr
. Для начала узнаем число отсутствующих значений в каждом столбце. Косая черта (\
) указывает на то, что мы используем анонимную функцию
library(tidyverse)
<- starwars
starwars map_int(starwars, \(x) sum(is.na(x)))
name height mass hair_color skin_color eye_color birth_year
0 6 28 5 0 0 44
sex gender homeworld species films vehicles starships
4 4 10 4 0 0 0
Обратите внимание, что map_int
, как и map_dbl
возвращает именованный вектор. Чтобы избавиться от имен, можно использовать unname()
.
Используйте map_int
и n_distinct
, чтобы узнать число уникальных наблюдений в каждом столбце.
Если функция принимает дополнительные аргументы, их можно задать после названия функции. В таком случае для каждого вызова функции будет использовано это значение аргумента. В примере ниже это аргумент na.rm
.
|>
starwars # выбираем все столбцы, где хранятся числовые значения
select_if(is.numeric) |>
map(mean, na.rm = TRUE)
$height
[1] 174.6049
$mass
[1] 97.31186
$birth_year
[1] 87.56512
При вызове map_df
есть дополнительная возможность сохранить названия столбцов, используя аргумент .id
:
|>
starwars map_df(~data.frame(unique_values = n_distinct(.x),
col_class = class(.x)),
.id = "variable"
)
4.11 map2()
Если необходимо несколько раз вызывать одну и ту же функцию с двумя аргументами, используется функция map2()
.
<- seq(10, 50, 10)
var1 <- seq(1, 5, 1)
var2
# формула
map2(var1, var2, ~.x+.y)
[[1]]
[1] 11
[[2]]
[1] 22
[[3]]
[1] 33
[[4]]
[1] 44
[[5]]
[1] 55
Аргументы, которые меняются при каждом вызове, пишутся до функции или формулы; аргументы, которые остаются неизменны, – после. Это можно представить так (источник):
Во всех случаеях, когда у функции больше двух аргументов, используется pmap()
.
Несколько вопросов для самопроверки.
4.12 Функционалы в анализе данных
Датасет “Гарри Поттер” представляет собой набор файлов .csv, содержащих метаданные о ресурсах из коллекций Британской библиотеки, связанных с Гарри Поттером, . Первоначально он был выпущен к 20-летию публикации книги «Гарри Поттер и философский камень» 26 июня 2017 года и с тех пор ежегодно обновлялся. Всего в датасете пять файлов, каждый из которых содержит разное представление данных.
Датасет до 2023 г. был доступен на сайте Британской библиотеки (https://www.bl.uk/); в репозитории курса сохранена его копия. Скачаем архив.
<- "https://github.com/locusclassicus/text_analysis_2024/raw/main/files/HP.zip"
my_url download.file(url = my_url, destfile = "../files/HP.zip")
После этого переходим в директорию с архивом и распаковываем его.
unzip("../files/HP.zip")
Сохраним список всех файлов с расширением .csv, используя подходящую функцию из base R.
<- list.files("../files/HP", pattern = ".csv", full.names = TRUE)
my_files my_files
[1] "../files/HP/classification.csv" "../files/HP/names.csv"
[3] "../files/HP/records.csv" "../files/HP/titles.csv"
[5] "../files/HP/topics.csv"
Теперь задействуем функционалы.
Функционалы – это функции, которые используют в качестве аргументов другие функции.
Для того, чтобы прочесть все файлы одним вызовом функции, используем map()
. В качестве аргументов передаем список файлов, функцию read_csv()
и аргумент этой функции col_types
.
# чтение файлов
<- map(my_files, read_csv, col_types = cols()) HP
Объект HP
– это список. В нем пять элементов, так как на входе у нас было пять файлов. Для удобства назначаем имена элементам списка.
<- list.files("../files/HP", pattern = ".csv")
my_files_short names(HP) <- my_files_short
Попробуем выяснить, какие столбцы есть во всех пяти таблицах. Для этого подойдет функция reduce()
из того же purrr
. Она принимает на входе вектор (или список) и функцию и применяет функцию последовательно к каждой паре значений.
|>
HP map(colnames) |>
# это тоже функционал
reduce(intersect)
[1] "Dewey classification" "BL record ID"
[3] "Type of resource" "Content type"
[5] "Material type" "BNB number"
[7] "ISBN" "ISSN"
[9] "Name" "Dates associated with name"
[11] "Type of name" "Role"
[13] "Title" "Series title"
[15] "Number within series" "Country of publication"
[17] "Place of publication" "Publisher"
[19] "Date of publication" "Edition"
[21] "Physical description" "BL shelfmark"
[23] "Genre" "Languages"
[25] "Notes"
Еще одна неочевидная возможность функции reduce
- объединение нескольких таблиц в одну одним вызовом. Например, так:
<- HP |>
HP_joined reduce(left_join)
HP_joined
О других возможностях пакета purrr
мы поговорим в следующем уроке, а пока почистим данные и построить несколько разведывательных графиков.
<- HP_joined |>
data_sum separate(`Date of publication`, into = c("year", NA)) |>
separate(Languages, into = c("language", NA), sep = ";") |>
mutate(language = str_squish(language)) |>
filter(!is.na(year)) |>
filter(!is.na(language)) |>
group_by(year, language) |>
summarise(n = n()) |>
arrange(-n)
data_sum
|>
data_sum ggplot(aes(year, n, fill = language)) +
geom_col() +
xlab(NULL) +
theme(axis.text.x = element_text(angle = 90))
Также построим облако слов. Для этого заберем первое слово в каждом ряду из столбца Topic
.
<- HP_joined |>
data_topics filter(!is.na(Topics)) |>
separate(Topics, into = c("topic", NA)) |>
mutate(topic = tolower(topic)) |>
group_by(topic) |>
summarise(n = n()) |>
filter(!topic %in% c("harry", "rowling", "potter", "children", "literary"))
<- c("#f1c40f", "#34495e",
pal "#8e44ad", "#3498db",
"#2ecc71")
library(wordcloud)
Loading required package: RColorBrewer
par(mar = c(1, 1, 1, 1))
wordcloud(data_topics$topic,
$n,
data_topicsmin.freq = 3,
#max.words = 50,
scale = c(3, 0.8),
colors = pal,
random.color = T,
rot.per = .2,
vfont=c("script","plain")
)
Интерактивное облако слов можно построить с использованием пакета wordcloud2
. Сделаем облако в форме шляпы волшебника!
# devtools::install_github("lchiffon/wordcloud2")
library(wordcloud2)
wordcloud2(data_topics,
figPath = "./images/hat.png",
size = 1.5,
backgroundColor="black",
color="random-light",
fontWeight = "normal",
)
Теперь попробуйте сами.
# постройте облако слов для "Алисы в стране чудес"
library(languageR)
library(dplyr)
library(tidytext)
# вектор с "Алисой"
<- tolower(alice)
alice
# частотности для слов
<- as_tibble(table(alice)) |>
freq rename(word = alice)
# удалить стоп-слова
<- freq |>
freq_tidy anti_join(stop_words)
# возможно, вы захотите произвести и другие преобразования
# облако можно строить в любой библиотеке