Персонажи «Войны и мира» Л. Н. Толстого: анализ сетей

Author

Ксения Войтова

Чтобы жить честно, надо рваться, путаться, биться, ошибаться, начинать и опять бросить, и опять начинать, и опять бросать, и вечно бороться и лишаться. А спокойствие — душевная подлость.

– из романа «Война и мир» Л. Н. Толстой

Введение

В данной работе исследуются взаимодействия персонажей романа-эпопеи «Война и мир» Л.Н. Толстого и визуализируются с помощью сети графов. Для анализа выбраны первые два тома — они охватывают ключевые события и основной круг персонажей, при этом сохраняя управляемый размер сети.

Цель исследования

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

Начало работы

library(xml2)
library(tidyverse)
library(igraph)
library(ggraph)
library(visNetwork)

1. Импорт

Первым делом нужно импортировать файл в формате .xml и определить пространство имен. Затем создадим функцию, которая принимает узел тома и возвращает таблицу всех реплик внутри него. xml_find_all ищет все теги на любой глубине вложенности, xml_attr извлекает атрибуты who (говорящий), corresp (адресат) и speech_text (текст).

doc <- read_xml("/Users/az/Documents/R scripts/War_and_Peace.xml")

ns <- xml_ns_rename(xml_ns(doc), d1 = "tei")

# определяем функцию
extract_speeches <- function(vol_node) {
  saids <- xml_find_all(vol_node, ".//*[local-name()='said']")
  tibble(
    speaker   = xml_attr(saids, "who"),
    addressee = xml_attr(saids, "corresp"),
    text      = xml_attr(saids, "speech_text")
  )
}

# узлы томов
text_node <- xml_find_first(doc, "//*[local-name()='text']")
vol1 <- xml_children(text_node)[[1]]
vol2 <- xml_children(text_node)[[2]]

# вызываем функцию через map
speeches <- map(list(vol1, vol2), extract_speeches) |>
  bind_rows() |>
  separate_rows(speaker,   sep = "[,;]") |>
  separate_rows(addressee, sep = "[,;]") |>
  mutate(
    speaker   = str_squish(speaker),
    addressee = str_squish(addressee)
  ) |>
  filter(!is.na(speaker), !is.na(addressee), speaker != "", addressee != "")

# Агрегирование ребер
edges <- speeches |>
  count(speaker, addressee, name = "weight") |>
  filter(speaker != addressee, weight >= 2)

nrow(speeches)
nrow(edges)
[1] 3644
[1] 380

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

2. Создание объекта графа и его описание

# Персонажи из XML
persons <- xml_find_all(doc, "//*[local-name()='person']")

characters <- tibble(
  id   = xml_attr(persons, "xml:id"),
  name = xml_text(xml_find_first(persons, ".//*[local-name()='persName']"), trim = TRUE)
) |>
  filter(!is.na(id))

# Узлы графа
all_nodes <- union(edges$speaker, edges$addressee)
nodes <- tibble(id = all_nodes) |>
  left_join(characters, by = "id") |>
  mutate(name = coalesce(name, id))

# Граф
g <- graph_from_data_frame(
  d        = edges,
  vertices = nodes,
  directed = FALSE
)

cat("Тип:", ifelse(is_directed(g), "ориентированный", "неориентированный"), "\n")
cat("Узлов:", vcount(g), "\n")
cat("Рёбер:", ecount(g), "\n")
cat("Компонент:", components(g)$no, "\n")
cat("Плотность:", round(edge_density(g), 4), "\n")
cat("Средний путь:", round(mean_distance(g), 2), "\n")
cat("Диаметр:", diameter(g), "\n")
Тип: неориентированный 
Узлов: 128 
Рёбер: 380 
Компонент: 2 
Плотность: 0.0468 
Средний путь: 10.01 
Диаметр: 33 

Выводы

Граф построен с помощью функции graph_from_data_frame из пакета igraph. Узлами являются персонажи, ребрами — факты диалогического взаимодействия между ними. Граф неориентированный: направление реплики (кто говорил первым) не учитывается, важен сам факт разговора. Атрибут ребер weight отражает суммарное количество реплик между парой персонажей.

3. Атрибуты ребер

# Проверяем атрибуты ребер
edge_attr_names(g)
[1] "weight"
# Топ-10 самых сильных связей
edges |> 
  arrange(desc(weight)) |> 
  head(10)
# A tibble: 10 × 3
   speaker          addressee                weight
   <chr>            <chr>                     <int>
 1 NatashaRostova   Nikolai_Rostov              123
 2 AndreyBolkonsky  Pierre_Bezukhov              92
 3 NatashaRostova   Sonya_Rostova                91
 4 Nikolai_Rostov   NatashaRostova               91
 5 Telyanin         Nikolai_Rostov               89
 6 NatashaRostova   Countess_Natalya_Rostova     83
 7 Boris_Drubetskoy Nikolai_Rostov               77
 8 Pierre_Bezukhov  AndreyBolkonsky              72
 9 Sonya_Rostova    NatashaRostova               57
10 Nikolai_Rostov   Vasily__Vasska__Denisov      51

Выводы

Единственным атрибутом ребер является weight — вес связи, равный количеству реплик между двумя персонажами в первых двух томах. Чем больше диалогов между персонажами, тем сильнее связь. Этот показатель отражает интенсивность взаимодействия: редкие случайные разговоры отличаются от устойчивых отношений между героями.

Связи с весом меньше 2 были исключены как незначимые — единственная реплика может быть случайным упоминанием, а не реальным взаимодействием. Наиболее сильные связи ожидаемо приходятся на главных героев и членов одних семей: Николай Ростов и Наташа Ростова, Наташи и Соня, Андрей Болконский и Пьер Безухов. Это подтверждает, что вес ребер осмысленно отражает близость персонажей в тексте.

4. Атрибуты узлов

Кто из персонажей самый важный и активный в первых двух томах?

V(g)$degree      <- degree(g)
V(g)$strength    <- strength(g, weights = E(g)$weight)
V(g)$betweenness <- betweenness(g, weights = E(g)$weight, normalized = TRUE)
V(g)$closeness   <- closeness(g, weights = E(g)$weight, normalized = TRUE)
V(g)$pagerank    <- page_rank(g, weights = E(g)$weight)$vector

tibble(
  name        = V(g)$name,
  degree      = V(g)$degree,
  strength    = V(g)$strength,
  betweenness = round(V(g)$betweenness, 4)
) |>
  arrange(desc(strength)) |>
  head(10)
# A tibble: 10 × 4
   name                        degree strength betweenness
   <chr>                        <dbl>    <dbl>       <dbl>
 1 Nikolai_Rostov                  66      873      0.314 
 2 NatashaRostova                  38      712      0.193 
 3 AndreyBolkonsky                 62      677      0.337 
 4 Pierre_Bezukhov                 43      539      0.158 
 5 Countess_Natalya_Rostova        18      224      0.0023
 6 Princess_Mariya_Bolkonskaya     15      202      0.018 
 7 Boris_Drubetskoy                15      191      0.0292
 8 Sonya_Rostova                    5      190      0     
 9 Vasili_Kuragin                  18      179      0.0345
10 Prince_Nikolay_Bolkonsky        20      172      0.0684

Выводы

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

В топе-10 персонажей лидируют Николай Ростов, Наташа Ростова и Андрей Болконский. При этом по метрике betweenness (посредничество) Андрей Болконский опережает Николая (0.337 против 0.313), то есть Болконский говорит реже, но занимает более стратегическую позицию в сети, соединяя светское общество, военную среду и семью Болконских.

5. Подграф: ego-граф

ego_node  <- which(V(g)$name == "AndreyBolkonsky")
ego_g     <- make_ego_graph(g, order = 1, nodes = ego_node)[[1]]

ggraph(ego_g, layout = "star", center = which(V(ego_g)$name == "AndreyBolkonsky")) +
  geom_edge_link(aes(width = weight), alpha = 0.4, colour = "steelblue") +
  geom_node_point(aes(size = strength, colour = name == "AndreyBolkonsky")) +
  geom_node_text(aes(label = name), repel = TRUE, size = 3) +
  scale_edge_width(range = c(0.3, 4)) +
  scale_colour_manual(values = c("TRUE" = "#d62728", "FALSE" = "#1f77b4"), guide = "none") +
  scale_size(range = c(3, 12)) +
  labs(
    title    = "Ego-граф Андрея Болконского"
  ) +
  theme_graph(base_family = "sans")

Выводы

Для построения подграфа выбран метод ego-графа 1 порядка (только прямые соседи) с центром в узле Андрея Болконского. Был выбран именно Андрей Болконский, так как его показатель betweenness (0.337) наиболее высокий среди остальных персонажей. Именно он чаще других оказывается на кратчайших путях между разными группами сети. Из этого можно сделать вывод, что он связывает светское общество Петербурга (Шерер, Курагины), свою семью и военную среду. Ego-граф позволяет наглядно увидеть это окружение.

Метод ego-графа предпочтителен в данном случае перед k-core или фильтрацией, поскольку позволяет сосредоточиться на конкретном персонаже и его непосредственном социальном круге, не теряя информацию о структуре связей внутри этого круга.

6. Основной граф

# Готовим узлы
vis_nodes <- tibble(
  id    = V(g)$name,
  label = V(g)$name,
  value = V(g)$strength,        # размер узла
  color.background = scales::col_numeric(
    palette = c("#d4e8f7", "#08306b"),
    domain  = range(V(g)$betweenness)
  )(V(g)$betweenness),
  title = paste0(     # всплывающая подсказка
    "<b>", V(g)$name, "</b><br>",
    "Degree: ", V(g)$degree, "<br>",
    "Strength: ", V(g)$strength, "<br>",
    "Betweenness: ", round(V(g)$betweenness, 3)
  )
)

# Готовим ребра
vis_edges <- tibble(
  from  = edges$speaker,
  to    = edges$addressee,
  value = edges$weight,     # толщина ребра
  title = paste0("Реплик: ", edges$weight)
)

# Визуализация
visNetwork(vis_nodes, vis_edges,
           main = "Сеть персонажей «Войны и мира» (1 и 2 том)") |>
  visNodes(
    scaling = list(min = 10, max = 50),
    font    = list(size = 14)
  ) |>
  visEdges(
    scaling = list(min = 1, max = 8),
    smooth  = list(type = "continuous")
  ) |>
  visOptions(
    highlightNearest = list(enabled = TRUE, degree = 1, hover = TRUE),
    nodesIdSelection = TRUE    # выпадающий список для поиска персонажа
  ) |>
  visLayout(randomSeed = 42) |>
  visPhysics(
    solver = "forceAtlas2Based",
    forceAtlas2Based = list(gravitationalConstant = -50),
      timestep = 0.4,       
  stabilization = list(
    enabled = TRUE,
    iterations = 200 
  )
)

Выводы

Основной граф визуализирован интерактивно с помощью пакета visNetwork. Он показывает несколько персонажей с высоким betweenness — Николай Ростов, Андрей Болконский, Наташа Ростова и Пьер Безухов. Размер узла отражает взвешенную степень персонажа (strength) — чем больше реплик, тем крупнее узел. Цвет узла кодирует показатель посредничества (betweenness) по градиенту от светло-голубого (низкое) до темно-синего (высокое). Толщина ребра соответствует весу связи. При наведении на узел отображается всплывающая подсказка с основными метриками персонажа, при клике подсвечиваются только его прямые связи. Выпадающий список позволяет найти любого персонажа по имени.

7. Модулярность

set.seed(42)
communities  <- cluster_louvain(g, weights = E(g)$weight)
V(g)$community <- membership(communities)

cat("Число сообществ:", length(communities), "\n")
cat("Модулярность:", round(modularity(communities), 4), "\n")
sizes(communities)
Число сообществ: 8 
Модулярность: 0.4577 
Community sizes
 1  2  3  4  5  6  7  8 
 9 15 44 43  3 10  2  2 

Выводы

Модулярность графа составляет 0.458. Это умеренно высокий показатель, который свидетельствует о том, что разбиение на классы хорошее. Алгоритм Лувена выделил 8 сообществ, два крупнейших из которых насчитывают 43 и 44 персонажа соответственно. Полученное значение модулярности подтверждает, что персонажи «Войны и мира» действительно общаются преимущественно внутри своих социальных групп, а не равномерно по всей сети.

8. Сообщество персонажей «Войны и мира» (1 и 2 том)

# Цвета для 8 сообществ
comm_colors <- c("#2c7bb6","#d7191c","#1a9641","#fdae61",
                 "#abd9e9","#a6d96a","#762a83","#e66101")

vis_nodes <- tibble(
  id        = V(g)$name,
  label     = V(g)$name,
  value     = V(g)$strength,
  group     = V(g)$community,
  color.background = comm_colors[V(g)$community],
  color.border     = "white",
  title = paste0(
    "<b>", V(g)$name, "</b><br>",
    "Сообщество: ", V(g)$community, "<br>",
    "Degree: ", V(g)$degree, "<br>",
    "Strength: ", V(g)$strength, "<br>",
    "Betweenness: ", round(V(g)$betweenness, 3)
  )
)

vis_edges <- tibble(
  from  = edges$speaker,
  to    = edges$addressee,
  value = edges$weight,
  title = paste0("Реплик: ", edges$weight),
  color = "rgba(150,150,150,0.3)"
)

visNetwork(vis_nodes, vis_edges,
           main = "Сообщества персонажей «Войны и мира» (1 и 2 том)") |>
  visNodes(
    scaling  = list(min = 10, max = 50),
    font     = list(size = 14, color = "black"),
    borderWidth = 2
  ) |>
  visEdges(
    scaling = list(min = 1, max = 6),
    smooth  = list(type = "continuous")
  ) |>
  visOptions(
    highlightNearest  = list(enabled = TRUE, degree = 1, hover = TRUE),
    nodesIdSelection  = TRUE,
    selectedBy        = list(variable = "group", main = "Выбрать сообщество")
  ) |>
  visLegend() |>
  visLayout(randomSeed = 42) |>
  visPhysics(
    solver = "forceAtlas2Based",
    forceAtlas2Based = list(gravitationalConstant = -80),
      timestep = 0.2,       
  stabilization = list(
    enabled = TRUE,
    iterations = 200 
  )
)

Выводы

Для выявления сообществ применен алгоритм Лувена. Алгоритм объединяет узлы в группы таким образом, чтобы связи внутри групп были плотнее, чем между ними. Louvain хорошо масштабируется и учитывает веса ребер, что важно в данном случае — интенсивность диалогов различается существенно.

Алгоритм выделил 8 сообществ. Два крупнейших насчитывают по 43 персонажа каждое. Интерактивная визуализация показывает, что Ростовы и их ближайшее окружение образуют отдельный кластер (оранжевый), поскольку их диалоги сосредоточены внутри семьи. Болконский и Безухов попадают в одно сообщество (зеленый) — они много общаются друг с другом и с одними и теми же людьми из военной и светской среды Петербурга. Курагины образуют отдельную группу вместе с персонажами светского общества.

9. Анализ ключевых узлов

ap <- articulation_points(g)
cat("Число точек сочленения:", length(ap), "\n")
cat("Точки сочленения:", V(g)$name[ap], "\n")

cliques_all <- cliques(g, min = 3)
cat("Клик размером >= 3:", length(cliques_all), "\n")

largest <- largest_cliques(g)
cat("Размер крупнейшей клики:", length(largest[[1]]), "\n")
cat("Крупнейшая клика:", V(g)$name[largest[[1]]], "\n")
Число точек сочленения: 20 
Точки сочленения: Fedor_Ivanovich_Dolokhov Count_Ilya_Rostov NatashaRostova old priest Princess_Anna_Mikhaylovna_Drubetskaya Pierre_Bezukhov Uncle regimental commander адъютант Zherkov Princess_Mariya_Bolkonskaya Prince_Nikolay_Bolkonsky Prince_Nesvitsky Nikolai_Rostov AndreyBolkonsky Prince_Kozlovsky Napoleon_Bonaparte Mikhail_Ilarionovich_Kutuzov Anatole_Kuragin Vasili_Kuragin 
Клик размером >= 3: 225 
Размер крупнейшей клики: 5 
Крупнейшая клика: Marya_Lvovna_Karagina Count_Ilya_Rostov Princess_Anna_Mikhaylovna_Drubetskaya NatashaRostova Countess_Natalya_Rostova 

Выводы

В графе найдено 20 точек сочленения — узлов, удаление которых разрывает граф на отдельные компоненты. Среди них все главные герои: Андрей Болконский, Николай Ростов, Наташа Ростова, Пьер Безухов, а также Кутузов и Наполеон. Это подтверждает их роль структурных мостов между разными социальными группами романа.

Найдено 225 клик размером не менее 3 узлов. Крупнейшая клика включает 5 персонажей: Наташу Ростову, графиню Ростову, графа Ростова, княжну Друбецкую и Марью Львовну Карагину — это круг московского дворянства, связанный семейными и светскими отношениями.

Заключение

Сетевой анализ первых двух томов «Войны и мира» позволил выявить структуру взаимодействий между персонажами и определить ключевых акторов романа. Граф включает 128 персонажей и 380 связей. Плотность сети 0.047 характерна для литературных текстов, так как чаще персонажи общаются внутри своего социального круга, а не равномерно по всей сети.

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