library(rvest)
library(tidyverse)
library(tidytext)
library(udpipe)
#remotes::install_github("dmafanasyev/rulexicon")
library(rulexicon)
library(wordcloud)Анализ речей Цицерона (в русских переводах)
Различия между речами, произнесенными против и в защиту кого-либо
Идея проекта
Идея заключается в том, чтобы выяснить, как различаются ораторские стратегии Цицерона, когда он защищает или, наоборот, обвиняет кого-либо в сенате или в суде. Для этого соберем корпус речей (в русских переводах) и проведем анализ тональности текстов.
Начало работы и сбор данных
Данные в готовом виде можно забрать по ссылке из репозитория. Файл cicero_speeches.RData содержит только тексты речей сразу после парсинга (без какой-либо обработки). Файлы speeches_in.RData и speeches_pro.RData содержат тексты речей, разбитые на токены и лемматизированные. Ниже в этом разделе представлен код для сбора данных. В следующем разделе представлен код для обработки данных.
Для работы используем следующие библиотеки:
Создадим функцию get_text(url), которая будет забирать текст речей по ссылке:
get_text <- function(url) {
Sys.sleep(3)
res = tibble(
link = url,
text = read_html(url) |>
html_elements(".otext") |>
html_text2())
return(res)
}Сохраним ссылку на оглавление, затем получим адреса ссылок на все нужные нам речи и запишем их в тиббл:
url <- 'https://ancientrome.ru/antlitr/cicero/index-or.htm'
html <- read_html(url)
toc <- html |>
html_elements("#kn a")
speeches <- tibble(
title = toc |>
html_text2(),
href = toc |>
html_attr("href")
)Удаляем несколько последних строк тиббла (они содержат повторяющиеся речи, но в другом переводе), затем оставляем только речи против и в защиту кого-либо. Дополняем ссылки до рабочего состояния:
speeches <- speeches |>
filter(row_number() < 30) |>
filter(str_detect(title, 'против|в защиту')) |>
mutate(link = str_c("https:", href)) |>
select(-href)Используем итератор и созданную выше функцию get_text(url), чтобы получить полные тексты речей по ссылкам, затем добавим к ним названия:
links <- speeches |>
pull(link)
res <- map_df(links, get_text)
res <- res |>
inner_join(speeches)Теперь разделим по разным тибблам речи против (speeches_in) и в защиту кого-либо (speeches_pro), чтобы затем анализировать их отдельно:
speeches_in <- res |>
filter(str_detect(title, 'против')) |>
select(-link) |>
relocate(title)
speeches_pro <- res |>
filter(str_detect(title, 'в защиту')) |>
select(-link) |>
relocate(title)Подготовка данных
Подготовка данных включает следующие шаги:
удалить предисловие редакторов к каждой речи (для этого заметим, что каждая речь начинается с сочетания символов (I, 1), что обозначает первую главу первого раздела)
разделить тексты на маленькие главы и сохранить номера этих глав (так мы получим кусочки текста примерно одинакового размера, это понадобится нам далее, чтобы проследить возможные изменения в эмоциональной окраске)
токенизировать тексты
для каждого токена получить лемму
#зададим паттерн для деления на главы
chapter_num <- '\\([:alpha:]*,? ?[:digit:]+\\)'
#выполним первые три пункта из списка
speeches_in <- speeches_in |>
mutate(text = str_replace(text, '[[:print:][:space:]]*\\(I, 1\\)', '\\(I, 1\\)')) |>
unnest_tokens(chapters, text, token='regex', pattern = chapter_num) |>
mutate(ch_num = row_number()) |>
relocate(ch_num, .after = title) |>
unnest_tokens(words, chapters)Для лематизации используем модель udpipe russian-syntagrus:
udpipe_download_model(language = "russian-syntagrus")
syntagrus <- udpipe_load_model(file = "russian-syntagrus-ud-2.5-191206.udpipe")Применим модель, из результатов ее работы оставим только леммы и добавим их как столбец к нашему рабочему тибблу:
speeches_in_annotate <- udpipe_annotate(syntagrus, speeches_in$words,
doc_id = speeches_in$ch_num)
speeches_in_lemmas <- speeches_in_annotate |>
as_tibble() |>
select(token, lemma) |>
rename(words = token)
speeches_in <- speeches_in |>
left_join(speeches_in_lemmas, multiple = 'first')Теперь все то же самое сделаем для речей в защиту (speeches_pro):
speeches_pro <- speeches_pro |>
mutate(text = str_replace(text, '[[:print:][:space:]]*\\(I, 1\\)', '\\(I, 1\\)')) |>
unnest_tokens(chapters, text, token='regex', pattern = chapter_num) |>
mutate(ch_num = row_number()) |>
relocate(ch_num, .after = title) |>
unnest_tokens(words, chapters)
speeches_pro_annotate <- udpipe_annotate(syntagrus, speeches_pro$words,
doc_id = speeches_pro$ch_num)
speeches_pro_lemmas <- speeches_pro_annotate |>
as_tibble() |>
select(token, lemma) |>
rename(words = token)
speeches_pro <- speeches_pro |>
left_join(speeches_pro_lemmas, multiple = 'first')Сентимент-анализ
Если данные были скачаны из репозитория, сейчас их нужно загрузить, чтобы начать работу:
load('speeches_in.RData')
load('speeches_pro.RData')Для анализа будем использовать метод словарей:
#подгружаем лексикон
afinn <- hash_sentiment_afinn_ru
#переименуем столбцы, чтобы можно было работать с лексиконом
speeches_in <- speeches_in |>
rename(token = lemma)
speeches_pro <- speeches_pro |>
rename(token = lemma)head(speeches_in)# A tibble: 6 × 4
title ch_num words token
<chr> <int> <chr> <chr>
1 2. Речь против Гая Верреса (первая сессия) 1 чего что
2 2. Речь против Гая Верреса (первая сессия) 1 всего всей
3 2. Речь против Гая Верреса (первая сессия) 1 более более
4 2. Речь против Гая Верреса (первая сессия) 1 надо надо
5 2. Речь против Гая Верреса (первая сессия) 1 было быть
6 2. Речь против Гая Верреса (первая сессия) 1 желать желать
В отдельных тибблах сохраним только слова, которые встречаются в лексиконе, то есть эмоционально окрашенные (для этого используем inner_join):
speeches_in_sent <- speeches_in |>
inner_join(afinn)
speeches_pro_sent <- speeches_pro |>
inner_join(afinn)Посмотрим, что будет, если просуммировать все значения score:
speeches_in_sent |>
group_by(title) |>
summarise(total = sum(score)) |>
arrange(-total) |>
print()# A tibble: 9 × 2
title total
<chr> <dbl>
1 2. Речь против Гая Верреса (первая сессия) 0.800
2 11. Третья речь против Катилины -22.5
3 12. Четвертая речь против Катилины -56.6
4 25. Первая филиппика против Марка Антония -58.3
5 10. Вторая речь против Катилины -62.3
6 9. Первая речь против Катилины -107.
7 3. Речь против Гая Верреса. «О предметах искусства» -194.
8 26. Вторая филиппика против Марка Антония -302.
9 4. Речь против Гая Верреса. «О казнях» -416.
speeches_pro_sent |>
group_by(title) |>
summarise(total = sum(score)) |>
arrange(-total) |>
print()# A tibble: 12 × 2
title total
<chr> <dbl>
1 Речь в защиту Луция Корнелия Бальба 18.3
2 15. Речь в защиту поэта Авла Лициния Архия 15.9
3 24. Речь в защиту Квинта Лигария -21.6
4 14. Речь в защиту Публия Корнелия Суллы -73.4
5 13. Речь в защиту Луция Лициния Мурены -104
6 8. Речь в защиту Гая Рабирия -108.
7 Речь в защиту Луция Валерия Флакка -169.
8 19. Речь в защиту Марка Целия Руфа -279.
9 18. Речь в защиту Публия Сестия -354.
10 22. Речь в защиту Тита Анния Милона -395.
11 1. Речь в защиту Секста Росция из Америй -413.
12 6. Речь в защиту Авла Клуенция Габита -504.
Мы видим, что в обоих подкорпусах преобладают негативно окрашенные слова
Облака слов
Посмотрим отдельно на позитивную и негативную лексику при помощи облаков слов. Сначала сделаем визуализации для обвинительных речей:
#для удобства выделим отдельно позитивную лексику, посчитаем частоты
positive_tokens_1 <- speeches_in_sent |>
filter(score > 0)
pos_token_freq_1 <- table(positive_tokens_1$token)
wordcloud(
words = names(pos_token_freq_1),
freq = pos_token_freq_1,
min.freq = 1, # минимальная частота для отображения
max.words = 100, # максимальное количество слов
random.order = FALSE, # слова не в случайном порядке
colors = brewer.pal(8, "Set1"), # цветовая палитра
scale = c(3, 0.5) # разброс размеров слов
)negative_tokens_1 <- speeches_in_sent |>
filter(score < 0)
neg_token_freq_1 <- table(negative_tokens_1$token)
wordcloud(
words = names(neg_token_freq_1),
freq = neg_token_freq_1,
min.freq = 1, # минимальная частота для отображения
max.words = 100, # максимальное количество слов
random.order = FALSE, # слова не в случайном порядке
colors = brewer.pal(8, "Set1"), # цветовая палитра
scale = c(3, 0.5) # разброс размеров слов
)Теперь сделаем аналогичные визуализации для речей в защиту кого-либо:
positive_tokens_2 <- speeches_pro_sent |>
filter(score > 0)
pos_token_freq_2 <- table(positive_tokens_2$token)
wordcloud(
words = names(pos_token_freq_2),
freq = pos_token_freq_2,
min.freq = 1, # минимальная частота для отображения
max.words = 100, # максимальное количество слов
random.order = FALSE, # слова не в случайном порядке
colors = brewer.pal(8, "Set1"), # цветовая палитра
scale = c(3, 0.5) # разброс размеров слов
)negative_tokens_2 <- speeches_pro_sent |>
filter(score < 0)
neg_token_freq_2 <- table(negative_tokens_2$token)
wordcloud(
words = names(neg_token_freq_2),
freq = neg_token_freq_2,
min.freq = 1, # минимальная частота для отображения
max.words = 100, # максимальное количество слов
random.order = FALSE, # слова не в случайном порядке
colors = brewer.pal(8, "Set1"), # цветовая палитра
scale = c(3, 0.5) # разброс размеров слов
)Вывод
Гипотеза о том, что Цицерон будет использовать разную по эмоциональной окраске лексику в речах за и против не подтвердилась. Мы видим, что негативно окрашенные слова преобладают в обоих подкорпусах, а на облаках слов выделяются одни и те же подтемы.