my_url <- "https://github.com/locusclassicus/text_analysis_2024/raw/main/files/HP.zip"
download.file(url = my_url, destfile = "../files/HP.zip")4 Циклы, условия, функции
В этом уроке мы познакомимся с итерационными конструкциями и функционалами, т.е. такими функциями, которые принимают в качестве аргумента другую функцию. Они нужны для того, чтобы можно было что-то сделать много раз: например, прочитать сразу 100 файлов из директории, построить и сохранить одной командой несколько графиков или сделать множество случайных выборок из большого текстового корпуса.
Общее правило таково: если вы скопировали кусок кода больше трех раз, то самое время задуматься об итерации. Это позволит избежать ошибок при копировании и сделает код более легким и читаемым.
4.1 Датасет
В этом уроке мы исследуем датасет “Гарри Поттер”, который представляет собой набор файлов .csv, содержащих метаданные о ресурсах из коллекций Британской библиотеки, связанных с Гарри Поттером. Первоначально он был выпущен к 20-летию публикации книги «Гарри Поттер и философский камень» 26 июня 2017 года и с тех пор ежегодно обновлялся. Всего в датасете пять файлов, каждый из которых содержит разное представление данных.
Датасет до 2023 г. был доступен на сайте Британской библиотеки (https://www.bl.uk/); в репозитории курса сохранена его копия. Скачаем архив.
После этого переходим в директорию с архивом и распаковываем его.
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"
4.2 Векторизованные вычисления
Хорошая новость: многие функции в 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
Мы написали цикл for, который считает количество букв для каждого слова в векторе. Как видно, все сработало. Но в R это избыточно, потому что nchar() уже векторизована:
nchar(homer)[1] 1 5 3 2 7 6 9 4
Лишний цикл может замедлить вычисления. Проверим.
library(tictoc)
# способ первый
tic()
for(i in homer) print(nchar(i))
toc()
# 0.075 sec elapsed
# способ второй
tic()
nchar(homer)
toc()
# 0.017 sec elapsedОдин из главных принципов программирования на R гласит, что следует обходиться без циклов, а если это невозможно, то циклы должны быть простыми.
— Нормат Мэтлофф
Для работы со списками циклы тоже чаще всего избыточны. Для случаев, когда надо применить какую-то функцию ко всем элементам списка, в базовом R для используются функционалы семейства _apply(), а в tidyverse их с успехом заменяет семейство map_() из пакета {purrr}.
4.3 Пакет {purrr}
Разработчики предупреждают, что потребуется время, чтобы овладеть этим инструментом (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.4 map() & map_int()
Для того, чтобы прочесть все файлы одним вызовом функции, используем map(). В качестве аргументов передаем список файлов, функцию read_csv() и аргумент этой функции col_types.
library(tidyverse)
# чтение файлов
HP <- map(my_files, read_csv, show_col_types = FALSE)Объект HP – это список. В нем пять элементов, так как на входе у нас было пять файлов. Для удобства назначаем имена элементам списка.
names_short <- list.files("../files/HP", pattern = ".csv") |>
str_remove(".csv")
names_short[1] "classification" "names" "records" "titles"
[5] "topics"
# присваиваем имена элементам списка
names(HP) <- names_shortДля начала узнаем число рядов в каждой таблице. На выходе мы ожидаем целое число, поэтому используем map_int(). Функция вернет именованный вектор. Чтобы избавиться от имен, можно использовать unname().
map_int(HP, nrow)classification names records titles topics
567 885 755 962 2198
4.5 pluck() & map_df()
Каждую таблицу можно также рассматривать как список (переменных) и применять функции (или их сочетания) к отдельным столбцам. Получить доступ к отдельным таблицам можно при помощи квадратных скобок (HP[["titles"]]) или при помощи функции pluck() из того же пакета {purrr}. Оба способа хороши, но второй удобнее при работе с вложенными списками, которая нас ждет в следующем уроке.
titles <- HP |>
pluck("titles")Теперь узнаем сумму отсутствующих значений в каждом столбце таблицы titles. Здесь .x — это текущий элемент из titles, который последовательно передаётся в функцию, а тильда ( ~ ) обозначает начало выражения-функции. Если вы явно передаёте именованную или анонимную функцию, то тильда не требуется.
map_int(titles, ~sum(is.na(.x))) Title Other titles
0 654
BL record ID Type of resource
0 0
Content type Material type
0 54
BNB number ISBN
363 47
ISSN Name
956 262
Dates associated with name Type of name
853 262
Role All names
617 138
Series title Number within series
753 907
Country of publication Place of publication
28 80
Publisher Date of publication
20 3
Edition Physical description
779 30
Dewey classification BL shelfmark
257 376
Topics Genre
158 499
Languages Notes
55 465
Также узнаем число уникальных значений в каждом столбце и запросим результат в виде таблицы.
map_df(
titles,
~ tibble(
n_unique = n_distinct(.x),
nas = sum(is.na(.x)),
prop_nas = round(nas / nrow(titles),2)
),
.id = "variable"
) |>
arrange(prop_nas) |>
print()# A tibble: 28 × 4
variable n_unique nas prop_nas
<chr> <int> <int> <dbl>
1 Title 698 0 0
2 BL record ID 755 0 0
3 Type of resource 4 0 0
4 Content type 8 0 0
5 Date of publication 34 3 0
6 Publisher 250 20 0.02
7 Country of publication 30 28 0.03
8 Physical description 522 30 0.03
9 ISBN 719 47 0.05
10 Material type 16 54 0.06
# ℹ 18 more rows
4.6 Разведывательный анализ
Выясним, на каких языках и когда выходили книги о Гарри Поттере. Но сначала приведем данные в опрятный вид. Там, где языков два, как правило интересен второй (первый - английский); поэтому делим столбец с языками на два и “сплавляем”. Функция coalesce(x, y) возвращает первое не-NA между x иy`.
titles_tidy <- titles |>
select(Title, Name, `Date of publication`, Languages) |>
# избавимся от неправильных имен
rename(
Year = `Date of publication`) |>
# избавимся от NA
filter(!is.na(Year), !is.na(Languages)) |>
# разделим кода с дефисом и преобразуем год
separate(Year, into = c("Year", NA)) |>
mutate(Year = as.integer(Year)) |>
# выбираем, где можно, второй язык
separate(Languages, into = c("Language1", "Language2"), sep = ";") |>
mutate(Language = coalesce(Language2, Language1)) |>
select(-Language1, -Language2)
titles_tidyДля графика выберем несколько языков. Кстати, ни одного перевода на русский в данных Британской библиотеки нет.
titles_tidy |>
add_count(Language) |>
filter(n > 10) |>
select(-n) |>
ggplot(aes(Year, fill = Language)) +
geom_bar(position = "stack") +
xlab(NULL) +
theme_light() +
theme(axis.text.x = element_text(angle = 45)) +
scale_x_continuous(breaks = seq(1997, 2023))
Теперь попробуем самостоятельно написать функцию и передать ее map_*().
4.7 Синтаксис функции
Функция и код – не одно и то же. Чтобы стать функцией, кусок кода должен получить имя. Но зачем давать имя коду, который и так работает? Вот три причины, которые приводит Хадли Уикхем:
- у функции есть выразительное имя, которое облегчает понимание кода;
- при изменении требований необходимо обновлять код только в одном месте, а не во многих;
- меньше вероятность случайных ошибок при копировании (например, обновление имени переменной в одном месте, но не в другом)
Writing good functions is a lifetime journey.
— Hadley Wickham
Машине все равно, как вы назовете функцию, но тем, кто будет читать код, не все равно. Имена должны быть информативы (поэтому функция f() – плохая идея). Также не стоит переписывать уже существующие в R имена!
Далее следует определить формальные аргументы и, при желании, значения по умолчанию. Тело функции пишется в фигурных скобках. В конце кода функции располагается команда return(); если ее нет, то функция возвращает последнее вычисленное значение (см. здесь о том, когда что предпочесть).
Написание функций – навык, который можно бесконечно совершенствовать. Начать проще всего с обычного кода. Убедившись, что он работает как надо, вы можете упаковать его в функцию.
Напишем функцию, которая забирает все слова из столбца, считает частотности и строит облако слов. Сначала просто поймем, какой код нам нужен.
library(tidytext)
count_data <- titles_tidy |>
filter(Language == "English") |>
select(Title) |>
unnest_tokens(output = "word", input = "Title") |>
anti_join(stop_words) |>
count(word, sort = TRUE) |>
filter(!str_detect(word, "[0-9]"),
!word %in% c("harry", "potter", "book"))
count_data |>
print()# A tibble: 902 × 2
word n
<chr> <int>
1 magical 50
2 magic 48
3 world 47
4 guide 43
5 hogwarts 38
6 unofficial 38
7 phoenix 37
8 secrets 37
9 stone 35
10 philosopher's 33
# ℹ 892 more rows
Также построим облако слов.
pal <- c("#f1c40f", "#34495e",
"#8e44ad", "#3498db",
"#2ecc71")
library(wordcloud)Loading required package: RColorBrewer
par(mar = c(1, 1, 1, 1))
wordcloud(count_data$word,
count_data$n,
min.freq = 3,
#max.words = 50,
#scale = c(3, 0.8),
colors = pal,
random.color = TRUE,
rot.per = .2,
vfont=c("script","plain")
)
Мы готовы упаковать наш код в функцию.
column_to_wordcloud <- function(data, colname, ...) {
# загружаем все пакеты
library(tidytext)
library(dplyr)
library(stringr)
library(wordcloud)
# пишем код, подставляя имена переменных
count_data <- data |>
filter(str_detect(Languages, "English")) |>
select(any_of(colname)) |>
unnest_tokens(output = "word", input = colname) |>
anti_join(stop_words) |>
count(word, sort = TRUE) |>
filter(!str_detect(word, "[0-9]"),
!word %in% c("harry", "potter", "book"))
wordcloud(count_data$word,
count_data$n,
...
)
}Попробуем запусть нашу новую функцию.
column_to_wordcloud(titles, "Topics")
Аргумент ... позволяет обращаться к аргументам функции wordcloud, которые мы специально не прописывали.
column_to_wordcloud(titles, "Topics", colors=pal, vfont=c("gothic english","plain"))
Внутри нашей функции есть переменная count_data, которую не видно в глобальном окружении. Это локальная переменная. Область ее видимости – тело функции. Когда функция возвращает управление, переменная исчезает. Обратное неверно: глобальные переменные доступны в теле функции.
4.8 map2(), walk2() & walk2()
Чтобы несколько раз вызывать одну и ту же функцию с двумя аргументами, используется функция map2_*(). Вот простой пример:
var1 <- seq(10, 50, 10)
var2 <- seq(1, 5, 1)
# формула
map2_int(var1, var2, ~.x+.y)[1] 11 22 33 44 55
Если сохранять ничего не надо (как в случае с нашим облаком слов), то используются walk() и walk2(). Попробуем.
par(mar = c(0,0,0,0), mfrow = c(1,2))
walk(HP[1:2], column_to_wordcloud, "Title", colors=pal)
В этом случае второй аргумент был одинаков для двух вызовов функции. Если второй аргумент отличается, вызываем walk2().
par(mar = c(0,0,0,0))
walk2(HP[1:2], c("Topics", "Publisher"), column_to_wordcloud, colors = c("navyblue", "magenta", "grey"), random.color = TRUE, scale = c(4,1))

4.9 Условия
Иногда необходимо ограничить выполнение функции неким условием. Короткие условия можно писать в одну строку без фигурных скобок.
word <- "Эйяфьятлайокудль"
if(is.character(word)) toupper(word)[1] "ЭЙЯФЬЯТЛАЙОКУДЛЬ"
Более сложные и множественные условия требуют фигурных скобок. Можно сравнить это с условным периодом: протасис (всегда либо TRUE, либо FALSE) в круглых скобках, аподосис в фигурных.
if(is.character(word)) {
toupper(word)
} else {
print("not a character")
}[1] "ЭЙЯФЬЯТЛАЙОКУДЛЬ"
Теперь добавим условие внутрь нашей функции.
Вот исходный вариант. На этот раз обойдемся без облака, а только посчитаем статистику для столбца.
column_counts <- function(data, colname) {
# загружаем пакеты
library(dplyr)
library(stringr)
library(tidytext)
# пишем код, подставляя имена переменных
count_data <- data |>
filter(str_detect(Languages, "English")) |>
select(any_of(colname)) |>
unnest_tokens(output = "word", input = colname) |>
anti_join(stop_words) |>
count(word, sort = TRUE)
return(count_data)
}column_counts(titles, "Genre")Дадим пользователю возможность выбрать, хочет ли он удалять стоп-слова.
column_counts <- function(data, colname, remove_stopwords = TRUE) {
library(dplyr)
library(tidytext)
library(stringr)
# базовая обработка
count_data <- data |>
select(any_of(colname)) |>
unnest_tokens(output = "word", input = colname)
# удаляем стоп-слова, если требуется
if (remove_stopwords) {
count_data <- count_data |>
anti_join(stop_words)
}
# частотности и сортировка
count_data <- count_data |>
count(word, sort = TRUE)
return(count_data)
}Применим.
column_counts(titles, "Title", remove_stopwords = FALSE)4.10 Сообщения и остановка
Часто имеет смысл добавить условие остановки или сообщение, которое будет распечатано в консоль при выполнении.
column_counts <- function(data, colname, remove_stopwords = TRUE) {
library(dplyr)
library(tidytext)
library(stringr)
# Условие остановки функции, если такого столбца нет
if (!(colname %in% names(data))) {
stop(paste("Столбец", colname, "не найден в данных!"))
}
# Сообщение о выбранном режиме стоп-слов
if (remove_stopwords) {
message("Стоп-слова будут удалены.")
} else {
message("Стоп-слова НЕ будут удалены.")
}
# базовая обработка
count_data <- data |>
select(any_of(colname)) |>
unnest_tokens(output = "word", input = colname)
# удаляем стоп-слова, если требуется
if (remove_stopwords) {
count_data <- count_data |>
anti_join(stop_words)
}
# частотности и сортировка
count_data <- count_data |>
count(word, sort = TRUE)
return(count_data)
}column_counts(titles, "Genre")column_counts(titles, "Date")Error in column_counts(titles, "Date"): Столбец Date не найден в данных!
4.11 Бонус: Интерактивное облако
Интерактивное облако слов можно построить с использованием пакета wordcloud2. Сделаем облако в форме шляпы волшебника!
title_counts <- titles |>
column_counts("Title") |>
rename(freq = n)
title_counts |>
print()# A tibble: 1,439 × 2
word freq
<chr> <int>
1 potter 790
2 harry 787
3 book 78
4 stone 62
5 philosopher's 56
6 magical 50
7 magic 48
8 secrets 48
9 world 48
10 phoenix 45
# ℹ 1,429 more rows
# devtools::install_github("lchiffon/wordcloud2")
library(wordcloud2)
wordcloud2(title_counts,
figPath = "./images/hat.png",
size = 1.5,
backgroundColor="black",
color="random-light",
fontWeight = "normal"
)
4.12 Видео к уроку
4.13 Домашнее задание
Напишите функцию count_words, которая будет:
- принимать на входе токенизированный (разбитый на слова) текст (символьный вектор!),
- переводить все слова в нижний регистр,
- считать частотность для каждого слова,
- упорядочивать по убыванию,
- возвращать n наиболее частотных слов (без частотностей, только слова!) в виде вектора.
N.B. Эту задачу можно решить разными способами; подойдет любой, если на выходе будут нужные слова. Как вы назовете аргументы функции, неважно. У аргумента для числа возвращаемых слов значение по умолчанию поставьте 10. Имя функции должно быть строго count_words.
Тренироваться можете на векторе languageR::alice, но в сдаваемом файле должна быть только функция.
Вам могут пригодиться функции table, sort, tolower, names – посмотрите документацию, чтобы понять, как они работают.
Файл под названием hw4.R загрузите в GitHub Classroom по ссылке до 11 октября 11:00 мск.