Анализ речей Цицерона (в русских переводах)

Различия между речами, произнесенными против и в защиту кого-либо

Autor:in

Ксения Дмитриева

Veröffentlichungsdatum

20. Dezember 2025

Zusammenfassung
Meow-meow

Идея проекта

Идея заключается в том, чтобы выяснить, как различаются ораторские стратегии Цицерона, когда он защищает или, наоборот, обвиняет кого-либо в сенате или в суде. Для этого соберем корпус речей (в русских переводах) и проведем анализ тональности текстов.

Начало работы и сбор данных

Данные в готовом виде можно забрать по ссылке из репозитория. Файл cicero_speeches.RData содержит только тексты речей сразу после парсинга (без какой-либо обработки). Файлы speeches_in.RData и speeches_pro.RData содержат тексты речей, разбитые на токены и лемматизированные. Ниже в этом разделе представлен код для сбора данных. В следующем разделе представлен код для обработки данных.

Для работы используем следующие библиотеки:

library(rvest)
library(tidyverse)
library(tidytext)
library(udpipe)
#remotes::install_github("dmafanasyev/rulexicon")
library(rulexicon)
library(wordcloud)

Создадим функцию 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)

Подготовка данных

Подготовка данных включает следующие шаги:

  1. удалить предисловие редакторов к каждой речи (для этого заметим, что каждая речь начинается с сочетания символов (I, 1), что обозначает первую главу первого раздела)

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

  3. токенизировать тексты

  4. для каждого токена получить лемму

#зададим паттерн для деления на главы
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)      # разброс размеров слов
)

Вывод

Гипотеза о том, что Цицерон будет использовать разную по эмоциональной окраске лексику в речах за и против не подтвердилась. Мы видим, что негативно окрашенные слова преобладают в обоих подкорпусах, а на облаках слов выделяются одни и те же подтемы.