library(readr)
library(tidyverse)
library(tidytext)
library(xml2)
library(dplyr)
library(widyr)
library(stopwords)
library(XML)
library(rvest)
library(udpipe)
library(tidyr)
library(purrr)Исследование тематических особенностей отечественной литературы последних 15 лет
На основе корпуса художественных текстов, опубликованных в журнале Зинзивер
Введение
Задача работы
Задача работы - проанализировать основные тематические особенности современных художественных текстов, выявить ключевые темы, интересующие авторов 21 века, а также, возможно, уловить жанровое распределение.
Материал ислледования
В качестве релевантного корпуса данных для решения поставленной задачи был выбран архив выпусков литературного журнала «Зинзивер».
«Зинзивер» — литературно-художественный журнал Союза писателей ХХI века, Союза писателей Санкт-Петербурга. Журнал был задуман в 2005 году московским поэтом и издателем Евгением Степановым и другими поэтами и художниками как сугубо поэтический журнал, ориентирующийся на экспериментальные и авангардные течения в прозе и поэзии. Названию журнала послужило стихотворение «Кузнечик» Велимира Хлебникова. Со временем формат издания стал шире, футуристические рамки отошли на второй план, стали публиковаться традиционные стихи и стихотворные переводы, короткая проза, публицистика, рецензии на книжные новинки.
Выбор был сделан по ряду причин:
1. Объем материала: “Зинзвер” выпускается довольно часто - 6 номеров в год, каждый из которых включает в себя около 15-20 текстов разных жанров.
2. Публикуемые тексты охватывают большое количество жанровых форм: стихи и стихотворные переводы, короткая проза, публицистика, рецензии на книжные новинки
3. Доступность архива номеров в открытых источниках (базу данных мы формировали из архива журнала, опубликованного на сайте Журнального Зала https://magazines.gorky.media/zin)
Сбор данных
На сайте журнального зала представлены выпуски номеров за 2009 - 2024 годы, Вооружимся храбростью и достанем каждый текст каждой “статьи” каждого выпуска каждого года!
Загрузка библиотек
Сбор данных
Открываем ссылки кажого года, достаем ссылки на отдельные выпуски, затем на “статьи”. Далее достаем отдельно - содержание (текст), название и имя автора.
Объем данных очень большой, поэтому мы закгрузили его отдельным файлом на 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("ужасный")Кажется, довольно заурядный список, но “наказание” лидирует (возможна ли социальная тематика?).
Термины и определеня были выбраны случайно, в некоторых узких целях можно исследовать и более конкретный набор слов.
Выводы
Результаты на самом деле нам очень нравятся, хоть в них и не прослеживаются именно художественные особенности. Вероятно, стоит попробовать разделить корпус по годам и посмотреть на динамику. Однозначно можно найти что-то интересное (но уже после новогодних каникул…). К тому же корпус не размечен пожанрово - а это отдельное поле для исследования!
Данные достаточно трудно как-то интерпретироать. Кажется, поставленные задачи не достигнуты, но результом мы все же довольны, потому что очень “чистые” резултаты получились! Но готовый корпус у нас есть - остается только изучать и изучать.