Тема 5 Функциональное программирование

5.1 Зачем писать функции?

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

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

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

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

Writing good functions is a lifetime journey.

— Hadley Wickham

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

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

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

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

x <- c("гнев", "богиня", "воспой")
y <- c("в", "мысли", "ему", "то", "вложила", "богиня", "державная", "гера")
idx <- which(x %in% y) # 2
x[idx]
## [1] "богиня"

Теперь заменяем фактические переменные на формальные.

common_words <- function(x, y){
  idx <- which(x %in% y)
  x[idx]
}

И применяем к новым данным.

x <- c("лишь", "явилась", "заря", "розоперстая", "вестница", "утра")
y <- c("вестница", "утра", "заря", "на", "великий", "олимп", "восходила")
common_words(x, y)
## [1] "заря"     "вестница" "утра"

Ура, все работает! Запомните простое правило: если вы трижды скопировали код, пора писать функцию!

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


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





5.2 Область видимости переменных

Напишем функцию, которая будет центрировать данные, то есть вычитать среднее из каждого значения (забудем на время, что это уже делает базовая scale()):

center <- function(x){ 
  n = x - mean(x)
  return(n) 
}

x <- c(5, 10, 15)
center(x) 
## [1] -5  0  5

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

5.3 Аргументы функции

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

center <- function(x, na.rm = F){
  if(na.rm) { x <- x[!is.na(x)]} # добавим условие
  x - mean(x) # на этот раз без return()
}

x <- c(5, 10, NA)
center(x)
## [1] NA NA NA

Что произошло? Почему следующий код выдает другой результат?

center(x, na.rm = T)
## [1] -2.5  2.5

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

center <- function(x, na.rm = F, what_is_your_name){
  if(na.rm) { x <- x[!is.na(x)]} # добавим условие
  x - mean(x) # на этот раз без return()
}

center(x, na.rm = T)
## [1] -2.5  2.5
center(x, na.rm = T, what_is_your_name = "Locusclassicus")
## [1] -2.5  2.5

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

center <- function(x){
  if (length(x) == 1) {stop("И без меня посчитаете")}
  x - mean(x) # на этот раз без return()
}

x <- 10
center(x) # вернет ошибку

5.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)\]

Понимание того, как действуют векторизованные вычисления, очень важно для написания корректного кода. Посмотрите на пример ниже: почему функция is_article() возвращает два значения, хотя на входе только одно?

is_article <- function(x){
  x == c("a", "the")
}

x <- "the"
is_article(x)
## [1] FALSE  TRUE

Поскольку векторы сравниваются поэлементно, то функция ниже вернет разный результат в зависимости от того, в каком порядке заданы элементы:

x <- c("just", "the")
is_article(x) 
## [1] FALSE  TRUE
x <- c("the", "just")
is_article(x) # взрыв мозга
## [1] FALSE FALSE


Подумайте, вектор какого типа и какой длины вернет код ниже.

is_article <- function(x) { 
  articles <- c("a", "the")
  x %in% articles
}

x <- c(rep("the", 5), rep("if", 5))
is_article(x)


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


5.5 Векторизованные конструкции

5.5.1 Циклы

Еще один способ повторить действия в R, при этом не копируя один и тот же код много раз, – это циклы.

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

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

Существует два основных цикла: цикл for и цикл while. На практике чаще используется цикл for, потому что цикл while легко отправить в бесконечность.

5.5.1.1 Цикл for

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

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

result <- c()
for(i in y) { 
  n <- nchar(i)
  result <- c(result, n)
}

result
## [1] 1 5 3 2 7 6 9 4

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

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

Поэтому чаще цикл for применяют к другим структурам данных. Например, к спискам и датафреймам. Загрузим и немного изменим датасет о гапаксах у Платона. Изменения нужны, так как цикл работает для данных только одного типа, в то время как в нашей таблице столбец dialogue содержит символьные строки, а group – фактор. Обратите внимание, что оператор pipe и функции из dplyr работают и с обычными датафреймами:

rownames(hapax_plato) <- hapax_plato$dialogue 

hapax_plato_num <- hapax_plato %>% select(-group, -dialogue) # 
str(hapax_plato_num)
## 'data.frame':    26 obs. of  3 variables:
##  $ words: chr  "8745" "8311" "17944" "4950" ...
##  $ hapax: chr  "36" "31" "122" "104" ...
##  $ ratio: chr  "0.004" "0.004" "0.007" "0.021" ...

Сейчас все данные в нашей таблице имеют тип chr, то есть строка, и при помощи цикла мы можем их трансформировать.

for (i in seq_along(hapax_plato_num)) {  # seq_along ≈ 1:length(x)
  hapax_plato_num[,i] <- as.numeric(hapax_plato_num[,i])
}

str(hapax_plato_num) # убеждаемся, что все получилось
## 'data.frame':    26 obs. of  3 variables:
##  $ words: num  8745 8311 17944 4950 4169 ...
##  $ hapax: num  36 31 122 104 19 87 15 125 12 32 ...
##  $ ratio: num  0.004 0.004 0.007 0.021 0.005 0.007 0.003 0.005 0.003 0.008 ...

При помощи циклов можно не только трансформировать данные, но и создавать новые. Чтобы посчитать среднее для столбца, цикл писать не надо: для этого есть функция colSums() (или, для других задач, rowSums()). А вот посчитать медиану таким образом не получится, тут может пригодиться цикл.

library(tictoc)

tic()
medians <- c()
for (i in seq_along(hapax_plato_num)) { 
  m <- median(hapax_plato_num[,i])
  medians <- c(medians, m)
}
toc()
## 0.003 sec elapsed
medians
## [1] 15589.500    94.500     0.007

Мы сохранили результат, инициировав пустой вектор, к которому затем привязали данные по каждому столбцу. Это не всегда хорошая идея, поскольку для больших данных может сильно замедлить цикл16. Еще один способ – сразу инициировать вектор нужной длины. Сравнить скорость можно при помощи функций из пакета tictoc.

tic()
medians <- vector("double", ncol(hapax_plato_num))
for (i in seq_along(hapax_plato_num)) { 
  medians[i] <- median(hapax_plato_num[,i])
}
toc()
## 0.003 sec elapsed

Второй способ чуть быстрее, и для больших данных это может быть существенно.

5.5.1.2 Цикл while

Как уже говорилось, с циклами while стоит быть осторожнее. Посмотрите, например, на этот цикл, который перебирает слова, пока не найдет слово длиной 6 букв. Что могло пойти не так?

tic()
k <- 0
n <- 0
while (n != 6) {
  k <- k + 1
  n <- nchar(y[k])
}
y[k]
## [1] "богиня"
toc()
## 0.004 sec elapsed

То же самое можно сделать без цикла, причем быстрее!

tic()
y[nchar(y) == 6][1] 
## [1] "богиня"
toc()
## 0.001 sec elapsed

В целом, ничего незаконного в циклах нет, но

  • множество вложенных друг в друга циклов сложно воспринимать;
  • порой они могут замедлить выполнение кода.

И в базовом R, и в диалекте tidyverse для этого есть несколько решений, о которых скажем чуть ниже. Сначала рассмотрим еще одну векторизованную конструкцию – условие.

5.5.2 Условия

Иногда необходимо ограничить выполнение функции неким условием. Короткие условия можно писать в одну строку без фигурных скобок.

if(any(nchar(y) > 6)) print("многабукв")
## [1] "многабукв"

Более сложные и множественные условия требуют фигурных скобок. Можно сравнить это с условным периодом: протасис (всегда либо TRUE, либо FALSE) в круглых скобках, аподосис в фигурных.

if (sum(nchar(y)) > 10) {
  print("много букв")
} else if (sum(nchar(y)) < 5) {
  print("мало букв")
} else {
  print("норм букв")
}
## [1] "много букв"

Также в R можно использовать специальную функцию:

ifelse((sum(nchar(y)) > 10), "много букв", "мало букв")
## [1] "много букв"

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

x <- c(1:10)
x >= 5
##  [1] FALSE FALSE FALSE FALSE  TRUE  TRUE  TRUE  TRUE  TRUE
## [10]  TRUE

Такое условие вернет ошибку.

if (x >= 5) print("все сломалось")
## Error in if (x >= 5) print("все сломалось"): the condition has length > 1

Можно скорректировать код так:

if (any(x >= 5)) print("все сработало")
## [1] "все сработало"

По той же причине внутри условия не надо использовать логические операторы | (“или”) или & (“и”), потому что они векторизованы:

x < 3 | x > 7
##  [1]  TRUE  TRUE FALSE FALSE FALSE FALSE FALSE  TRUE  TRUE
## [10]  TRUE


ПРАКТИЧЕСКОЕ ЗАДАНИЕ 4: ГАРРИ ПОТТЕР


# скачайте с сайта Британской библиотеки датасет о Гарри Поттере
my_url <- "https://www.bl.uk/bibliographic/downloads/HarryPotterResearcherFormat_202306_csv.zip"
dir.create("HP")
download.file(url = my_url, destfile = "HP/HP.zip")

# после этого перейдите в директорию с архивом 
# и распакуйте его 

setwd("HP")
unzip("HP.zip")

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

# ваш код здесь
# my_files <- 

# напишите цикл, который:
# 1) прочитает все файлы из my_files, используя для этого функцию read_csv() из пакета readr
# (аргумент show_col_types установите на FALSE);
# 2) для каждого датасета выяснит количество рядов _без_ NA в столбце BNB Number;
# 3) разделит число таких рядов на общее число рядов;
# 4) вернет таблицу c четырьми столбцами: 
# - название файла (id), 
# - число рядов (total), 
# - число рядов без NA (complete), 
# - доля полных рядов (ratio)

my_df <- data.frame(id = my_files, 
                    total = rep(0, length(my_files)),
                    complete = rep(0, length(my_files)),
                    ratio = rep(0, length(my_files)))

for (i in 1:length(my_files)) {
  # ваш код здесь
}

5.6 Вместо циклов: семейство _apply

Функция tapply() из базового R принимает на входе вектор, фактор (или список факторов) и функцию. Каждый фактор должен быть той же длины, что и вектор. Код ниже считает среднюю долю гапаксов по группам диалогов:

# подготавливаем векторы 
my_fct <- as.factor(hapax_plato$group)
my_vct <- as.numeric(hapax_plato$ratio)

# применяем к ним функцию mean()
tapply(my_vct, my_fct, mean)
##          1          2          3 
## 0.00550000 0.00750000 0.01133333


Подумайте, как решить эту задачу на диалекте tidyverse.


Если группировка не нужна, то подойдет функция apply(): она вызывает функцию для каждого ряда или столбца матрицы или датафрейма. В качестве второго аргумента функция принимает 1 (для вычислений по рядам) или 2 (для вычислений по столбцам).

tic()
round(apply(hapax_plato_num, 2, mean), 3)
##     words     hapax     ratio 
## 19364.423   146.692     0.007
toc()
## 0.001 sec elapsed

Эти функции работают быстрее циклов. Сравните:

tic()
my_means <- c()
for (i in seq_along(hapax_plato_num)) {  
  m <- round(mean(hapax_plato_num[,i]), 3)
  my_means <- c(my_means, m)
}
my_means
## [1] 19364.423   146.692     0.007
toc()
## 0.003 sec elapsed

Функция apply() позволяет применять к данным собственные функции, в том числе анонимные.

hapax_centered <- apply(hapax_plato_num, 2, function(x) x - mean(x))
head(hapax_centered)
##                 words      hapax         ratio
## Apology    -10619.423 -110.69231 -0.0031538462
## Charmides  -11053.423 -115.69231 -0.0031538462
## Cratylus    -1420.423  -24.69231 -0.0001538462
## Critias    -14414.423  -42.69231  0.0138461538
## Crito      -15195.423 -127.69231 -0.0021538462
## Euthydemus  -6911.423  -59.69231 -0.0001538462

Также можно использовать грамматику трансформации данных dplyr:

tic()
as_tibble(hapax_plato_num) %>% 
  mutate(words = words - mean(words), 
         hapax = hapax - mean(hapax),
         ratio = ratio - mean(ratio))
toc()

Видно, что по времени мы при этом сильно не выигрываем; к тому же, нам пришлось повторить один код три раза. Значит, надо что-то менять. Например, так17:

tic()
as_tibble(hapax_plato_num) %>% 
  mutate_all(function(x) x - mean(x))
toc()

Или даже так18:

fn <- function(x) x - mean(x)
as_tibble(hapax_plato_num) %>%
  mutate(across(1:3, fn)) %>% 
  invisible()

В любом случае, нам удалось обойтись без цикла, код понятен и хорошо читается.

Функции lapply() и sapply() подходят для применения функций к спискам (и к датафреймам, которые по сути представляют собой прямоугольные списки).

Чтобы понять, как они работают, сначала создадим список.

При анализе текста со списками приходится иметь дело достаточно часто: объекты типа stylo.corpus, которые создает пакет stylo по сути являются списками.

Создадим игрушечный корпус из двух текстов.

x <- c("гнев", "богиня", "воспой")
y <- c("в", "мысли", "ему", "то", "вложила", "богиня", "державная", "гера")
corpus <- list(x = x, y = y)

Наш условный корпус – это список из 2 элементов (текстов), а каждый текст хранится как символьный вектор. Допустим, мы хотим взять из каждого текста выборку размером 5 слов, то есть применить функцию sample() к элементам списка. При помощи lapply() (l = list) это делается так:

set.seed(0211) 
lapply(corpus, sample, 5, replace = T)
## $x
## [1] "воспой" "воспой" "воспой" "богиня" "воспой"
## 
## $y
## [1] "державная" "вложила"   "то"        "мысли"    
## [5] "вложила"

Функция sapply() ведет себя так же, но упрощает результат до вектора или матрицы (s = simplify).

sapply(corpus, sample, 5, replace = T)
##      x        y          
## [1,] "богиня" "ему"      
## [2,] "гнев"   "державная"
## [3,] "гнев"   "ему"      
## [4,] "богиня" "то"       
## [5,] "воспой" "мысли"

Функция vapply() позволяет задать тип данных на выходе.

vapply(corpus, sample, size = 5, replace = T, character(5))
##      x        y        
## [1,] "воспой" "то"     
## [2,] "богиня" "гера"   
## [3,] "гнев"   "вложила"
## [4,] "гнев"   "гера"   
## [5,] "гнев"   "мысли"

Поскольку наш “корпус” – это список, применять грамматику dplyr не очень удобно. Но списко легко превращается в таблицу:

corpus_tbl <- stack(corpus) %>% 
  as_tibble()
corpus_tbl
## # A tibble: 11 × 2
##    values    ind  
##    <chr>     <fct>
##  1 гнев      x    
##  2 богиня    x    
##  3 воспой    x    
##  4 в         y    
##  5 мысли     y    
##  6 ему       y    
##  7 то        y    
##  8 вложила   y    
##  9 богиня    y    
## 10 державная y    
## 11 гера      y

Используйте relocate() и rename(), чтобы поменять столбцы местами и переименовать их.


Повторные выборки из тиббла можно делать так:

set.seed(0211)
corpus_tbl %>% 
  group_by(ind) %>% 
  sample_n(size = 5, replace = T)
## # A tibble: 10 × 2
## # Groups:   ind [2]
##    values    ind  
##    <chr>     <fct>
##  1 воспой    x    
##  2 воспой    x    
##  3 воспой    x    
##  4 богиня    x    
##  5 воспой    x    
##  6 державная y    
##  7 вложила   y    
##  8 то        y    
##  9 мысли     y    
## 10 вложила   y


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


5.7 Purrr

5.7.1 map()

По-настоящему мощный инструмент для итераций – это пакет purrr из семейства tidyverse19. Разработчики предупреждают, что потребуется время, чтобы овладеть этим инструментом (Wickham and 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 вариации20. Вот основные из них:

  • map()
  • map_lgl()
  • map_int()
  • map_dbl()
  • map_chr()

Все они принимают на входе данные и функцию (или формулу), которую следует к ним применить, и возвращают результат в том виде, который указан после подчеркивания. Просто map() вернет список, а map_int() – целочисленный вектор, и т.д.

Воспользуемся возможностями purrr, чтобы исследовать датасет starwars из пакета dplyr. Для начала узнаем число отсутствующих значений в каждом столбце. Тильда (~) указывает на то, что мы используем формулу.

library(purrr)
data(starwars)
map_int(starwars, ~sum(is.na(.)))
##       name     height       mass hair_color skin_color 
##          0          6         28          5          0 
##  eye_color birth_year        sex     gender  homeworld 
##          0         44          4          4         10 
##    species      films   vehicles  starships 
##          4          0          0          0

Обратите внимание, что map_int, как и map_dbl возвращает именованный вектор. Чтобы избавиться от имен, можно использовать unname():

unname(map_chr(starwars, class))
##  [1] "character" "integer"   "numeric"   "character"
##  [5] "character" "character" "numeric"   "character"
##  [9] "character" "character" "character" "list"     
## [13] "list"      "list"


Используйте map_dbl и n_distinct, чтобы узнать число уникальных наблюдений в каждом столбце.


Если функция принимает дополнительные аргументы, их можно задать после названия функции. В таком случае для каждого вызова функции будет использовано это значение аргумента. В примере ниже это аргумент na.rm. Заметьте, что map можно использовать с оператором pipe %>%:

starwars %>% 
  select(mass, height) %>% 
  map(mean, na.rm = TRUE)
## $mass
## [1] 97.31186
## 
## $height
## [1] 174.358

При вызове map_df есть дополнительная возможность сохранить названия столбцов, используя аргумент .id:

starwars %>% 
  map_df(~data.frame(distinct = n_distinct(.x),
                     class = class(.x)),
         .id = "variable"
         )
##      variable distinct     class
## 1        name       87 character
## 2      height       46   integer
## 3        mass       39   numeric
## 4  hair_color       13 character
## 5  skin_color       31 character
## 6   eye_color       15 character
## 7  birth_year       37   numeric
## 8         sex        5 character
## 9      gender        3 character
## 10  homeworld       49 character
## 11    species       38 character
## 12      films       24      list
## 13   vehicles       11      list
## 14  starships       17      list

Функции map можно передать пользовательскую функцию. Для примера создадим функцию describe_vec(), которая возвращает строку с длиной и классом вектора, и применим ее к игрушечному корпусу, который мы создали выше.

# пользовательская функция
describe_vec <- function(vec){
  l = paste("Длина вектора: ", length(vec))
  c = paste("Класс вектора: ", class(vec))
  result = paste(l, c, sep = " | ")
  return(result)
}

unname(map_chr(corpus, describe_vec))
## [1] "Длина вектора:  3 | Класс вектора:  character"
## [2] "Длина вектора:  8 | Класс вектора:  character"

Кроме того, мы можем передать map анонимную функцию:

map_chr(corpus, 
        function(x) paste("Длина вектора: ", length(x))
        )
##                   x                   y 
## "Длина вектора:  3" "Длина вектора:  8"

5.7.2 map2()

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

mean = list(5, 10, -3)
sd = list(1, 5, 50)
map2(mean, sd, rnorm, n = 5)
## [[1]]
## [1] 5.727572 4.812749 4.528140 6.565481 4.356073
## 
## [[2]]
## [1] 18.6183347  0.5775309 23.3592929  3.2133071 15.5702820
## 
## [[3]]
## [1] -10.13466  51.54818 -23.10523 -12.53555 -66.80735


_Как работает map2()_
_Как работает map2()_


Это можно обобщить следующим образом (источник):

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

В анализе текстовых данных функция map2() применяется, например, при создании скользящего окна. Такие окна используются при создании эмбеддингов.22

Разделим наш игрушечный корпус на окна. Функция вернет список с тибблами, число рядов в каждом из которых соответствует заданному размеру окна.

windows <- slider::slide(corpus_tbl, ~.x, .after = 1)
windows[[1]]
## # A tibble: 2 × 2
##   values ind  
##   <chr>  <fct>
## 1 гнев   x    
## 2 богиня x

Для анализа этих данных каждому окну следует присвоить id. Здесь пригодится map2().

out <- map2(.x = windows, .y = 1:length(windows), ~ mutate(.x, window_id = .y)) # out  -- это список
out[[1]]
## # A tibble: 2 × 3
##   values ind   window_id
##   <chr>  <fct>     <int>
## 1 гнев   x             1
## 2 богиня x             1

Поскольку аргумент .y – это, по сути, индекс, можно было бы использовать функцию imap():

out <- imap(.x = windows, ~ mutate(.x, window_id = .y))
out[[1]]
## # A tibble: 2 × 3
##   values ind   window_id
##   <chr>  <fct>     <int>
## 1 гнев   x             1
## 2 богиня x             1

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

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

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











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











5.7.3 Дополнительные возможности purrr

Вы уже поняли, что благодаря циклам можно прочитать сразу несколько файлов (см. Практическое задание выше). Та же задача решается и при помощи map. Рассмотрим на том же примере с датасетом Британской библиотеки про Гарри Поттера. Если вы еще не скачали архив, посмотрите выше, как это сделать.

Узнаем имена файлов в директории и прочитаем их все одним вызовом функции (подробнее про формат .csv будет рассказано в следующем уроке).

# чтение файлов 
library(readr)

files <- list.files("./files/HP", pattern = ".csv", full.names = TRUE)

HP <- map(files, read_csv, col_types = cols())

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

library(stringr)
names(HP) <- str_extract(files, "\\w+(?=.csv)")
names(HP)
## [1] "classification" "names"          "records"       
## [4] "titles"         "topics"

Начнем с простого: при помощи map можно извлечь столбцы (по имени) или ряды (по условию) из всех пяти таблиц. Прежде чем выполнить код ниже, подумайте, как будет выглядеть результат.

# извлечь столбцы
map(HP, select, `BNB number`)

# извлечь ряды
map(HP, filter, !(is.na(`BNB number`)))


Извлеките все уникальные названия (столбец Title) из всех пяти таблиц в HP. Используйте функцию distinct.


Что, если мы не знаем заранее, какие столбцы есть во всех пяти таблицах, и хотим это выяснить? Для этого подойдет функция reduce() из того же purrr. Она принимает на входе вектор (или список) и функцию и применяет функцию последовательно к каждой паре значений.


Воспользуемся этим, чтобы найти общие для всех таблиц имена столбцов.

map(HP, colnames) %>% 
  reduce(intersect)
##  [1] "Dewey classification"      
##  [2] "BL record ID"              
##  [3] "Type of resource"          
##  [4] "Content type"              
##  [5] "Material type"             
##  [6] "BNB number"                
##  [7] "ISBN"                      
##  [8] "ISSN"                      
##  [9] "Name"                      
## [10] "Dates associated with name"
## [11] "Type of name"              
## [12] "Role"                      
## [13] "Title"                     
## [14] "Series title"              
## [15] "Number within series"      
## [16] "Country of publication"    
## [17] "Place of publication"      
## [18] "Publisher"                 
## [19] "Date of publication"       
## [20] "Edition"                   
## [21] "Physical description"      
## [22] "BL shelfmark"              
## [23] "Genre"                     
## [24] "Languages"                 
## [25] "Notes"

Еще одна неочевидная возможность функции reduce - объединение нескольких таблиц в одну одним вызовом. Например, так:

HP_joined <- HP %>% 
  reduce(left_join)

Теперь можно почистить данные и построить несколько разведывательных графиков.

library(ggplot2)
library(tidyr)
data_sum <- HP_joined %>% 
  separate(`Date of publication`, into = c("year", NA)) %>% 
  separate(`Country of publication`, into = c("country", NA), sep = ";") %>%
  mutate(country = str_squish(country)) %>% 
  mutate(country = 
           case_when(country == "England" ~ "United Kingdom",
                     country == "Scotland" ~ "United Kingdom",
                     TRUE ~ country)) %>% 
  group_by(year, country) %>% 
  summarise(n = n()) %>% 
  filter(!is.na(year)) %>% 
  filter(!is.na(country)) 
  

# график
data_sum %>% 
  ggplot(aes(year, n, fill = country)) + 
  geom_col() + 
  xlab(NULL) +
  theme(axis.text.x = element_text(angle = 90))

В качестве небольшого бонуса к этому уроку построим облако слов. Вектор слов возьмем из столбца Topic.

library(tidyr)
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)
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,
           color="random-light", 
           fontWeight = "normal",
           backgroundColor="black"
           )


ПРАКТИЧЕСКОЕ ЗАДАНИЕ 5: АЛИСА В СТРАНЕ ЧУДЕС


# постройте облако слов для "Алисы в стране чудес"

library(languageR)
library(dplyr)
library(tidytext)

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

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

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

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

Литература

Wickham, Hadley, and Garrett Grolemund. 2016. R for Data Science. O’Reilly. https://r4ds.had.co.nz/index.html.