Исследование тематических особенностей отечественной литературы последних 15 лет

На основе корпуса художественных текстов, опубликованных в журнале Зинзивер

Автор

Смолянинова Дарья

Дата публикации

23.12.2024

Введение

Задача работы

Задача работы - проанализировать основные тематические особенности современных художественных текстов, выявить ключевые темы, интересующие авторов 21 века, а также, возможно, уловить жанровое распределение.

Материал ислледования

В качестве релевантного корпуса данных для решения поставленной задачи был выбран архив выпусков литературного журнала «Зинзивер».

«Зинзивер» — литературно-художественный журнал Союза писателей ХХI века, Союза писателей Санкт-Петербурга. Журнал был задуман в 2005 году московским поэтом и издателем Евгением Степановым и другими поэтами и художниками как сугубо поэтический журнал, ориентирующийся на экспериментальные и авангардные течения в прозе и поэзии. Названию журнала послужило стихотворение «Кузнечик» Велимира Хлебникова. Со временем формат издания стал шире, футуристические рамки отошли на второй план, стали публиковаться традиционные стихи и стихотворные переводы, короткая проза, публицистика, рецензии на книжные новинки.

Журнал «Зинзивер»

Выбор был сделан по ряду причин:

1. Объем материала: “Зинзвер” выпускается довольно часто - 6 номеров в год, каждый из которых включает в себя около 15-20 текстов разных жанров.

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

3. Доступность архива номеров в открытых источниках (базу данных мы формировали из архива журнала, опубликованного на сайте Журнального Зала https://magazines.gorky.media/zin)

Сбор данных

На сайте журнального зала представлены выпуски номеров за 2009 - 2024 годы, Вооружимся храбростью и достанем каждый текст каждой “статьи” каждого выпуска каждого года!

Загрузка библиотек

library(readr)
library(tidyverse)
library(tidytext)
library(xml2)
library(dplyr)
library(widyr)
library(stopwords)
library(XML)
library(rvest)
library(udpipe)
library(tidyr)
library(purrr)

Сбор данных

Открываем ссылки кажого года, достаем ссылки на отдельные выпуски, затем на “статьи”. Далее достаем отдельно - содержание (текст), название и имя автора.

Объем данных очень большой, поэтому мы закгрузили его отдельным файлом на GitHub (https://github.com/Daria-Smolyaninova/zinziver_research/blob/main/archive.zip)

dataset <- read.csv2("articles_zinziver.csv", header = TRUE, sep = ",")
dataset

Мы забрали уже чистый датасет, но код ему предществующий можно посмотреть ниже.

# читаем ссылку
html <- read_html("https://magazines.gorky.media/zin")
# выбираем ссылки на выпуски журналов
data <- html %>% 
  html_elements("div a")
# создаем таблицу, кладём в неё все ссылки на выпуски, убираем пустые значения, добавляем "ссылочный префикс" 
urls <- tibble(
  title = data |>
  html_attr("href")
) |> 
  filter(!is.na(title)) |>
  rename(link = title)
# оставляем только нужные значения
urls <- urls[50:188, ]
# перекладываем ссылки на выпуски из таблицы в вектор 
my_urls <- urls |> 
  pull(link)
# функция для чтения ссылкок на выпуски
get_article_url <- function(url) {
  read_html(url) |> 
  html_elements("p a") |> 
  html_attr("href")
}
# применяем функцию
articles_just_url <- map(my_urls, get_article_url)
#таблица со всеми ссылками на выпуски 
articles_urls <- tibble(
  text = (articles_just_url)
) |>
  unnest(text)
# преобразовываем данные из столбца со ссылками в вектор
articles_www <- articles_urls |> 
  pull(text)
#функция для "чтения" основного текста из ссылок 
get_articles <- function(url) {
  read_html(url) |> 
  html_elements("div p") |> 
  html_text2() |> 
  paste(collapse= " ")
}
# применяем функцию - достаем тексты
articles <- map(articles_www, get_articles)
#функция для "чтения" имени и названия статьи из ссылок 
get_name <- function(url) {
  read_html(url) |> 
  html_elements("section h2") |> 
  html_text2() |> 
  paste(collapse= " ")
}
# применяем функцию - достаем названия статей
names <- map(articles_www, get_name)
#функция для "чтения" автора статьи из ссылок 
get_author <- function(url) {
  read_html(url) |> 
  html_elements("h4 a") |> 
  html_text2() |> 
  paste(collapse = " ")
}
# применяем функцию - достаем имена авторов
authors <- map(articles_www, get_author)

Чистка данных

Наконец, кладем все собранные данные в одну большущую таблицу! И делаем из нее конфетку.

# соединяем все в одну таблицу
articles_tbl <- tibble(
  text = articles,
  name = names,
  author = authors
) %>% 
  unnest(text) %>% 
  separate('text', into = c('other', 'text'), sep = 'Зинзивер, номер ') %>% 
  separate('text', into = c('issue', 'text'), sep = 8) %>% 
  separate('issue', into = c('issue', 'year'), sep = ', ') %>% 
  select(-other)

POS-теггинг

# POS-ТЕГГИНГ
# загружаем модель
russian_syntagrus <- udpipe_load_model(file = "russian-syntagrus-ud-2.5-191206.udpipe")
# аннотируем
articles_annotate <- udpipe_annotate(russian_syntagrus, articles_tbl$text, doc_id = articles_tbl$name)
# ГОТОВАЯ ТАБЛИЦА POS
dataset_pos <- as_tibble(articles_annotate) 

Оставляем только необходимые части речи, убираем стоп-слова

#задаем стоп-слова
necessary_tokens <- c('NOUN', 'VERB', 'ADJ')
stopwords_ru <- c(
  stopwords("ru", source = "snowball"),
  stopwords("ru", source = "marimo"),
  stopwords("ru", source = "nltk"), 
  stopwords("ru", source  = "stopwords-iso")
)
# оставляем только уникальные
stopwords_ru <- sort(unique(stopwords_ru))
# зададим персональный список стоп-слов (в нашем случае - это слова, которые очень часто встречаются в "карточке" автора)
my_stop_words <- c('Лауреат', 'автор', 'Автор', 'Писатель', 'лауреат', 'писатель', 'член', 'живет', 'журнал', 'нет', 'может', 'стихи', 'поэт', 'прозаик', 'произведение', 'публикация', 'число')
#чистим данные
dataset_pos <- dataset_pos_dirty %>% 
  filter(upos %in% necessary_tokens) %>% 
  filter(!lemma %in% stopwords_ru) %>% 
  filter(!lemma %in% my_stop_words) %>% 
  select(doc_id, lemma) %>% 
  rename(token = lemma)
# датасет со сгрупированными словами
clean_dataset <- dataset_pos%>% 
  group_by(token) %>% 
  count()
# убираем слова, которые начинаются со всяких некрасивых символов (они обычно стоят в начале и конце)
clean_dataset <- clean_dataset[1275:134299, ] %>% 
  filter(n > 10) %>% 
  select(-n)
# итоговый датасет
dataset_pos <- dataset_pos %>% 
  filter(token %in% clean_dataset$token)

Опытным путем было выяcнено:

1. Строить векторную модель пространства на токенах - плохая затея, лучше ограничиться леммами (тогда соотношение векторов будет более точным);

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

3. Классно сработал фильтр частотности вхождения слова в корпус (мы поставили не менее 10 вхождений) - картина сразу стала более четкой;

4. Полезно всегда смотреть на данные глазами: без сложных фильтров удалось убрать 300 тысяч “грязных” токенов, лишь срезав начало и конец таблицы.

Векторное представление слов

Создание контекстых окон

library(tidyr)
# создаем контекстные окна
dataset_nested <- dataset %>% 
  nest(tokens = c(token))
#функция для создания скипграмм
slide_windows <- function(tbl, window_size) {
  skipgrams <- slider::slide(
    tbl, 
    ~.x, 
    .after = window_size - 1, 
    .step = 1, 
    .complete = TRUE
  )
  
  safe_mutate <- safely(mutate)
  
  out <- map2(skipgrams,
              1:length(skipgrams),
              ~ safe_mutate(.x, window_id = .y))
  
  out %>%
    transpose() %>%
    pluck("result") %>%
    compact() %>%
    bind_rows()
}
#контекстные окна
articles_windows <- dataset_nested %>% 
  mutate(tokens = map(tokens, slide_windows, 10L)) %>% 
  unnest(tokens) %>% 
  unite(window_id, doc_id, window_id)

Уберем отрицательные значения векторов

# PMI
articles_pmi  <- articles_windows  |> 
  pairwise_pmi(token, window_id)
#
articles_pmi |> 
  arrange(-abs(pmi))
# уберем отрицательные значения векторов
articles_ppmi <- articles_pmi |> 
  mutate(ppmi = case_when(pmi < 0 ~ 0, 
                          .default = pmi)) 
# расставим по возрастанию
articles_ppmi |> 
  arrange(pmi)

Сингулярное разложение

word_emb <- articles_ppmi |> 
  widely_svd(item1, item2, ppmi,
             weight_d = FALSE, nv = 50) |> 
  rename(word = item1)

Анализ данных

Тематическое распределение

Для начала посмотрим на тематическое распределение слов в текстах.

word_emb |> 
  filter(dimension < 10) |>
  group_by(dimension) |> 
  top_n(10, abs(value)) |> 
  ungroup() |> 
  mutate(word = reorder_within(word, value, dimension)) |> 
  ggplot(aes(word, value, fill = dimension)) +
  geom_col(alpha = 0.8, show.legend = FALSE) +
  facet_wrap(~dimension, scales = "free_y", ncol = 3) +
  scale_x_reordered() +
  coord_flip() +
  labs(
    x = NULL, 
    y = "Value",
    title = "9 групп слов в отечественной литературе за последние 15 лет",
    subtitle = "Топ-10 слов"
  ) +
  scale_fill_viridis_c()

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

Очень многие группы (которые еще довольно осмыслены) скорее свидетельсвуют о верной настройке. Но тяготение к обширной гастрономии явно прослеживается(хи-хи).

Ближайшие соседи

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

Функция для поиска:

nearest_neighbors <- function(df, feat, doc=F) {
  inner_f <- function() {
    widely(
      ~ {
        y <- .[rep(feat, nrow(.)), ]
        res <- rowSums(. * y) / 
          (sqrt(rowSums(. ^ 2)) * sqrt(sum(.[feat, ] ^ 2)))
        
        matrix(res, ncol = 1, dimnames = list(x = names(res)))
      },
      sort = TRUE
    )}
  if (doc) {
    df |> inner_f()(doc, dimension, value) }
  else {
    df |> inner_f()(word, dimension, value)
  } |> 
    select(-item2)
}

Любовь?

word_emb |> 
  nearest_neighbors("любовь")

Нет ни одного негативно окрашенного слова! Какая у нас счасливая литература, оказывается!

Счастливый?

word_emb |> 
  nearest_neighbors("счастливый")

ооооу

Зимний?

word_emb |> 
  nearest_neighbors("зимний")

Вот, кстати, появилась аналогия знаменитой пары “король-королева”. В нашем случае - зима и осень!

Красивый

word_emb |> 
  nearest_neighbors("красивый")

1. Каштановый всё-таки лидурует?!

2. Красивые мужчины тоже в моде!

3. Бодипозитив-бодипозитивом, но эмбэдинги врать не будут! (см. “стройный”)

Ужасный?

word_emb |> 
  nearest_neighbors("ужасный")

Кажется, довольно заурядный список, но “наказание” лидирует (возможна ли социальная тематика?).

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

Выводы

Результаты на самом деле нам очень нравятся, хоть в них и не прослеживаются именно художественные особенности. Вероятно, стоит попробовать разделить корпус по годам и посмотреть на динамику. Однозначно можно найти что-то интересное (но уже после новогодних каникул…). К тому же корпус не размечен пожанрово - а это отдельное поле для исследования!

Данные достаточно трудно как-то интерпретироать. Кажется, поставленные задачи не достигнуты, но результом мы все же довольны, потому что очень “чистые” резултаты получились! Но готовый корпус у нас есть - остается только изучать и изучать.