4  Циклы, условия, функции

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

Общее правило таково: если вы скопировали кусок кода больше трех раз, то самое время задуматься об итерации. Это позволит избежать ошибок при копировании и сделает код более легким и читаемым.

4.1 Векторизованные вычисления

Хорошая новость: многие функции в R уже векторизованы, и если необходимо применить функцию к каждому элементу вектора, в большинстве случаев достаточно просто вызвать функцию. Это называется векторизация.

Например, у нас есть символьный вектор, и мы хотим узнать количество символов в каждом слове.

homer <- c("в", "мысли", "ему", "то", "вложила", "богиня", "державная", "гера")

Для каждого компонента вектора необходимо выполнить одну итерацию цикла, в нашем случае – применить функцию nchar(). В некоторых языках программирования это делается как-то так:

for(i in homer) print(nchar(i))
[1] 1
[1] 5
[1] 3
[1] 2
[1] 7
[1] 6
[1] 9
[1] 4
На заметку

В циклах часто используется буква i. Но никакой особой магии в ней нет, имя переменной можно изменить.

Мы написали цикл for, который считает количество букв для каждого слова в векторе. Как видно, все сработало. Но в R это избыточно, потому что nchar() уже векторизована:

nchar(homer)
[1] 1 5 3 2 7 6 9 4

Это относится не только ко многим встроенным функциям R, но и к даже к операторам. x + 4 в действительности представляет собой +(x, 4):

x <- c(1.2, 2.51, 3.8)

`+`(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)
x <- c(2, 3, 5, 7, 11, 13)

# способ первый
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()
x^2 - 100
[1] -96 -91 -75 -51  21  69
toc()
0.001 sec elapsed

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

— Нормат Мэтлофф

4.2 Семейство _apply()

Для работы со списками циклы тоже чаще всего избыточны. Снова воспользуемся списком печенек из коллекции rcorpora.

library(rcorpora)
my_list <-  corpora("foods/breads_and_pastries")

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(). Сравните.

df <- data.frame(author=c("Joe","Jane"), year=c(1801,1901), reprints=c(TRUE,FALSE))

## цикл 
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.

Задание

Пройдите урок 10 lapply and sapply и урок 11 vapply and tapply из курса R Programming в swirl.

Тем не менее, перед освоением семейства map_() стоит потренироваться работать с обычными циклами, особенно если вам не приходилось иметь с ними дела (например, на Python). Несмотря на все недостатки, цикл for интуитивно понятен и часто проще начинать именно с него.

Задание

Превратите детскую потешку “Ted in the Bed” в функцию. Обобщите до любого числа спящих.

4.3 Синтаксис функций

Функция и код – не одно и то же. Чтобы стать функцией, кусок кода должен получить имя. Но зачем давать имя коду, который и так работает?

Вот три причины, которые приводит Хадли Уикхем:

  • у функции есть выразительное имя, которое облегчает понимание кода;
  • при изменении требований необходимо обновлять код только в одном месте, а не во многих;
  • меньше вероятность случайных ошибок при копировании (например, обновление имени переменной в одном месте, но не в другом)

Writing good functions is a lifetime journey.

— Hadley Wickham

Машине все равно, как вы назовете функцию, но тем, кто будет читать код, не все равно. Имена должны быть информативы (поэтому функция f() – плохая идея). Также не стоит переписывать уже существующие в R имена!

Далее следует определить формальные аргументы и, при желании, значения по умолчанию. Тело функции пишется в фигурных скобках. В конце кода функции располагается команда return(); если ее нет, то функция возвращает последнее вычисленное значение (см. здесь о том, когда что предпочесть).

Написание функций – навык, который можно бесконечно совершенствовать. Начать проще всего с обычного кода. Убедившись, что он работает как надо, вы можете упаковать его в функцию.

Напишем функцию, которая будет переводить градусы по Фаренгейту в градусы по Цельсию.

fahrenheit_to_celsius <- function(fahrenheit){ 
  celsius = (fahrenheit - 32) / 1.8
  return(round(celsius))
}

fahrenheit_to_celsius(451)
[1] 233

Внутри нашей функции есть переменная celsius, которую не видно в глобальном окружении. Это локальная переменная. Область ее видимости – тело функции. Когда функция возвращает управление, переменная исчезает. Обратное неверно: глобальные переменные доступны в теле функции.

Задание

Напишите функцию, которая ищет совпадения в двух символьных векторах и возвращает совпавшие элементы.

Задание

Загрузите библиотеку swirl, выберите курс R Programming и пройдите из него урок 9 Functions.

Вопрос

Для просмотра исходного кода любой функции необходимо…






4.4 Ленивые вычисления

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

fahrenheit_to_celsius <- function(fahrenheit, your_name = "locusclassicus"){ 
  celsius = (fahrenheit - 32) / 1.8
  return(round(celsius))
}

fahrenheit_to_celsius(451)
[1] 233
Задание

Напишите функцию awesome_plot, которая будет принимать в качестве аргументов два вектора, трансформировать их в тиббл и строить диаграмму рассеяния при помощи ggplot(). Задайте цвет и прозрачность точек.

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] "много букв"

Прописывая условие, не забывайте, что применение булева оператора к вектору возвращает логический вектор:

x <- 1:10
x >= 5
 [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 Условия внутри функций

Функция может принимать произвольное число аргументов. Доработаем наш код:

convert_temperature <- function(x, mode = "f_to_c"){ 
  if(mode == "f_to_c") {
    celsius = round((x - 32) / 1.8)
    return(paste(celsius, "градусов по Цельсию"))
  } else if (mode == "c_to_f") {
    fahrenheit = round(x * 1.8 + 32)
    return(paste(fahrenheit, "градусов по Фаренгейту"))
  }
}

convert_temperature(84)
[1] "29 градусов по Цельсию"
convert_temperature(29, mode = "c_to_f")
[1] "84 градусов по Фаренгейту"

4.7 Сообщения и условия остановки

Часто имеет смысл добавить условие остановки или сообщение, которое будет распечатано в консоль при выполнении.

convert_temperature <- function(x, mode = "f_to_c"){
  if(!is.numeric(x)) stop("non-numeric input")
  
  message("Please, wait...")
  if(mode == "f_to_c") {
    celsius = round((x - 32) / 1.8)
    return(paste(celsius, "градусов по Цельсию"))
  } else if (mode == "c_to_f") {
    fahrenheit = round(x * 1.8 + 32)
    return(paste(fahrenheit, "градусов по Фаренгейту"))
  }
}

convert_temperature("двадцать пять")
Error in convert_temperature("двадцать пять"): non-numeric input
convert_temperature(78)
Please, wait...
[1] "26 градусов по Цельсию"

4.8 switch()

Слишком много условий в теле функции могут сделать ее нечитаемой. Для таких случаев подойдет switch().

convert_temperature <- function(x, mode = "f_to_c"){
  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().

var1 <- seq(10, 50, 10)
var2 <- seq(1, 5, 1)

# формула
map2(var1, var2, ~.x+.y)
[[1]]
[1] 11

[[2]]
[1] 22

[[3]]
[1] 33

[[4]]
[1] 44

[[5]]
[1] 55

Аргументы, которые меняются при каждом вызове, пишутся до функции или формулы; аргументы, которые остаются неизменны, – после. Это можно представить так (источник):

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

Задание

Установите курс swirl::install_course("Advanced R Programming") и пройдите из него урок 3 Functional Programming with purrr.

Несколько вопросов для самопроверки.

Вопрос

Функции-предикаты (predicate functions) возвращают TRUE или FALSE. Выберите из списка все функции-предикаты.











Вопрос

Какие из функций ниже принимают в качестве аргумента функции-предикаты?










4.12 Функционалы в анализе данных

Датасет “Гарри Поттер” представляет собой набор файлов .csv, содержащих метаданные о ресурсах из коллекций Британской библиотеки, связанных с Гарри Поттером, . Первоначально он был выпущен к 20-летию публикации книги «Гарри Поттер и философский камень» 26 июня 2017 года и с тех пор ежегодно обновлялся. Всего в датасете пять файлов, каждый из которых содержит разное представление данных.

Датасет до 2023 г. был доступен на сайте Британской библиотеки (https://www.bl.uk/); в репозитории курса сохранена его копия. Скачаем архив.

my_url <- "https://github.com/locusclassicus/text_analysis_2024/raw/main/files/HP.zip"
download.file(url = my_url, destfile = "../files/HP.zip")

После этого переходим в директорию с архивом и распаковываем его.

unzip("../files/HP.zip")

Сохраним список всех файлов с расширением .csv, используя подходящую функцию из base R.

my_files <- list.files("../files/HP", pattern = ".csv", full.names = TRUE)
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.

# чтение файлов 
HP <- map(my_files, read_csv, col_types = cols())

Объект HP – это список. В нем пять элементов, так как на входе у нас было пять файлов. Для удобства назначаем имена элементам списка.

my_files_short <- list.files("../files/HP", pattern = ".csv")
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_joined <- HP |> 
  reduce(left_join)

HP_joined

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

data_sum <- HP_joined |> 
  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.

data_topics <- HP_joined |> 
  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"))
pal <- c("#f1c40f", "#34495e", 
         "#8e44ad", "#3498db",
         "#2ecc71")

library(wordcloud)
Loading required package: RColorBrewer
par(mar = c(1, 1, 1, 1))
wordcloud(data_topics$topic, 
          data_topics$n,
          min.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)

# вектор с "Алисой"
alice <- tolower(alice)

# частотности для слов
freq <- as_tibble(table(alice)) |> 
  rename(word = alice)

# удалить стоп-слова
freq_tidy <- freq |> 
  anti_join(stop_words) 
# возможно, вы захотите произвести и другие преобразования

# облако можно строить в любой библиотеке