library(quanteda.textstats)
library(udpipe)
library(stopwords)
library(tidyverse)
library(textrecipes)
library(tidymodels)
library(tidytext)
library(stylo)
library(baguette)
library(discrim)
library(future)
library(devtools)
library(learntidymodels)
library(embed)
Исследование стилистических особенностей британских авторов
с применением tidymodels
Так как я прикрепляю только qmd-файл, вот опубликованный проект со всем изображениями по этой ссылке. Чтобы ускорить обработку, все графики не строились здесь, а были заранее сохранены в формате jpeg.
Для удобства анализа лингвистических признаков, преобразуем исходный корпус в табличный формат.
<- "files/british_corpus"
text_folder <- list.files(path = text_folder, pattern = "\\.txt$", full.names = TRUE)
files
<- tibble(
text_corpus filename = basename(files),
text = map_chr(files, readr::read_file)
)
Подготовка текста: очищаем от пунктуации(оставляет точки и заглавные буквы в начале предложений – это будет необходимо при подсчете средней длины предложений).
<- text_corpus|>
corpus_clean mutate(
text = text|>
#цифры и лишняя пунктуация
str_remove_all("[0-9]")|>
str_replace_all("[[:punct:]&&[^.]]", "")|>
#разбивка на предложения
str_split("\\.\\s+")|>
map(~ {
#первую букву каждого предложения делаем заглавной
str_sub(.x, 1, 1) <- str_to_upper(str_sub(.x, 1, 1))
#остальные - строчные
str_sub(.x, 2) <- str_to_lower(str_sub(.x, 2))
paste(.x, collapse = ". ")
|>
})unlist()|>
#лишние пробелы
str_squish()
)
Для посчета средних значений используем пакет textstat, в который уже вложены нужные формулы.
#СРЕДНЯЯ ДЛИНА ПРЕДЛОЖЕНИЙ И СЛОВ БЕЗ УДАЛЕНИЯ СТОП-СЛОВ
<- textstat_readability(corpus_clean$text, "meanSentenceLength")
sent
<- mutate(text_corpus, sent$meanSentenceLength)|>
sent_length select(-text)|>
rename(ASL = `sent$meanSentenceLength`)
ggplot(sent_length, aes(x = filename, y = ASL)) +
geom_col(fill = "steelblue") +
coord_cartesian(ylim = c(0, 50)) +
labs(title = "Средняя длина предложения по текстам",
x = "Текст",
y = "Средняя длина предложения") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
#средняя длина слова в каждом тексте(по кол-ву слогов)
<- textstat_readability(text_corpus$text, "meanWordSyllables")
words
<- mutate(text_corpus, words$meanWordSyllables)|>
word_length select(-text)|>
rename(AWL = `words$meanWordSyllables`)
ggplot(word_length, aes(x = filename, y = AWL)) +
geom_col(fill = "steelblue") +
coord_cartesian(ylim = c(0, 2)) +
labs(title = "Средняя длина слова по текстам",
x = "Текст",
y = "Средняя длина слова") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
Поскольку в английском языке большУю часть текста занимают служебные части речи, можно попробовать избавиться от них при помощи очистки от стоп-слов, а затем посмотреть на среднюю длину слова еще раз. Увидим, что показатели повысились, но незначительно. Однако разница между текстам стала различимее.
#СРЕДНЯЯ ДЛИНА СЛОВА ПОСЛЕ УДАЛЕНИЯ СТОП-СЛОВ
<- c(
stopwords_en stopwords("en", source = "snowball"),
stopwords("en", source = "marimo"),
stopwords("en", source = "nltk"),
stopwords("en", source = "stopwords-iso")
)
<- sort(unique(stopwords_en))
stopwords_en
<- corpus_clean |>
corpus_no_stopwords mutate(
text = map_chr(text, ~ {
<- unlist(str_split(.x, "\\s+"))
words <- words[!words %in% stopwords_en]
words paste(words, collapse = " ")
})
)
<- textstat_readability(corpus_no_stopwords$text, "meanWordSyllables")
words_no_stop
<- mutate(corpus_no_stopwords, words_no_stop$meanWordSyllables)|>
word_length_no_stop select(-text)|>
rename(AWL = `words_no_stop$meanWordSyllables`)
ggplot(word_length_no_stop, aes(x = filename, y = AWL)) +
geom_col(fill = "steelblue") +
coord_cartesian(ylim = c(0, 3)) +
labs(title = "Средняя длина слова по текстам (по кол-ву слогов) после удаления стоп-слов",
x = "Текст",
y = "Средняя длина слова") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
Теперь посмотрим на долю стоп-слов в каждом тексте. На барчарте видно, что ничего слишком выделяющегося у какого-либо конкретного автора нет. Считать абсолютную частоту в данном случае не стоит, поскольку романы Ричардсона в несколько раз превышают остальны по количеству слов, а поэтому и стоп-слов там будет больше.
#СТАТИСТИКА ПО СТОП-СЛОВАМ
<- corpus_clean |>
corpus_with_metrics mutate(
word_stats = map(text, ~{
<- unlist(str_split(.x, "\\s+"))
words <- length(words)
total <- sum(words %in% stopwords_en)
stopwords list(
total_words = total,
stopword_count = stopwords,
stopword_ratio = stopwords / total
)
})|>
)unnest_wider(word_stats)
ggplot(corpus_with_metrics, aes(x = filename, y = stopword_ratio)) +
geom_col(fill = "tomato") +
coord_cartesian(ylim = c(0, 1)) +
labs(title = "Доля стоп-слов в текстах",
x = "Текст",
y = "Кол-во стоп-слов") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
Переходим к части с классификацией. Поскольку в случае подбора модели нам важны в том числе служебные слова, стоп-слова мы оставляем. Функция load.corpus.and.parse выдает текст очищенным от цифр и пункцуации, приведенным к нижнему регистру. Однако необходимо избавиться от именованных сущностей (пример: если бы мы анализировали корпус русского канона, то одним из самых частых слов там был бы “Пьер” и “князь”, потому что роман Толстого занимал бы много места в общем корпусе).
Для решения этой задачи можно было бы использовать морфологическую разметку, однако корпус слишком большой. В таком случае необходимо вспомнить, что именованные сущности уникальны практически для каждого текста. То есть имя героя романа Диккенса, вряд ли будет так же часто встречаться в романе Остен. Используем для этого “высеивание” – в итоговую выборку попадут только те слова, которые встречаются в 90% текстов. Таким образом мы избежим попадания уникальных имен и топонимов в финальный корпус.
Поскольку количество сэмплов будет слишком неравомерным(если взять всех авторов для анализа), приходится отсеить несколько авторов “с конца”.
<- load.corpus.and.parse(corpus.dir = "british_corpus")
corpus
<- make.samples(corpus,
corpus_samples sample.size = 2000,
sampling = "normal.sampling",
sample.overlap = 0,
sampling.with.replacement = FALSE)
<- make.frequency.list(corpus_samples)[1:500]
mfw
<- stylo::make.table.of.frequencies(corpus_samples, mfw) |>
corpus_tf as.data.frame.matrix() |>
rownames_to_column("id") |>
as_tibble(
)
<- perform.culling(corpus_tf, culling.level = 90)
corpus_tf_new
<- corpus_tf_new |>
corpus_tf_new separate(id, into = c("author", "title", NA), sep = "_")
|>
corpus_tf_new count(author) |>
ggplot(aes(reorder(author, n), n, fill = author)) +
geom_col(show.legend = FALSE) +
xlab(NULL) +
ylab(NULL) +
scale_fill_viridis_d() +
theme_light() +
coord_flip()
<- corpus_tf_new |>
corpus_top add_count(author) |>
filter(n >= 200) |>
select(-n, -title)
|>
corpus_top count(author) |>
ggplot(aes(reorder(author, n), n, fill = author)) +
geom_col(show.legend = FALSE) +
xlab(NULL) +
ylab(NULL) +
scale_fill_viridis_d() +
theme_light() +
coord_flip()
Создаем тренировочные и тестовые данные. Разбиваем тренировочные данные на 5 “фолдов”. Внутри базового рецепта прописываем несколько предикторов:
step_zv необходим в случае если какой-то признак имеет дисперсию равную 0. В этом случае этот признак бракуется.
step_normalize выполняет стандартизацию числовых данных, приводя их к единому масштабу.
step_impute_mean используется для обработки пропущенных значений в данных, заменяя их средним значением (mean) по столбцу. В нем появилась необходимость на моменте обучения, когда начались ошибки при обработке данных.
set.seed(07062025)
<- corpus_top |>
data_split mutate(author = as.factor(author)) |>
initial_split(strata = author)
<- training(data_split)
data_train <- testing(data_split)
data_test
set.seed(07062025)
<- vfold_cv(data_train, strata = author, v = 5)
folds
<- recipe(author ~ ., data = data_train) |>
base_rec step_zv(all_predictors()) |>
step_normalize(all_predictors())|>
step_impute_mean(all_numeric_predictors())
Также создадим рецепт, в котором используем главные компоненты в качестве предикторов. Мы отдаем рецепту данные и говорим: тренируйся (= преобразуй эти данные во что-то, на основе чего мы будем осуществлять обучение).
<- base_rec |>
pca_rec step_pca(all_predictors(), num_comp = 7)
<- base_rec |>
base_trained prep(data_train)
base_trained
Далее мы передаем полученное функции bake, еще не снижая размерность. Обратим внимание, что группы не слишком разделены.
|>
base_trained bake(new_data = NULL)
<- pca_rec |>
pca_trained prep(data_train)
|>
pca_trained juice()
|>
pca_trained juice() |>
ggplot(aes(PC1, PC2, color = author)) +
geom_point() +
theme_light()
Дообучаем модель и увидим, что авторы на графике разделились явнее.
<- base_trained |>
pls_trained step_pls(all_numeric_predictors(), outcome = "author", num_comp = 7) |>
prep()
|>
pls_trained juice()
|>
pls_trained juice() |>
ggplot(aes(PLS1, PLS2, color = author)) +
geom_point() +
theme_light()
Выведем также нагрузки компонент.
|>
pls_trained plot_top_loadings(component_number <= 4, n = 10, type = "pls") +
scale_fill_brewer(palette = "Paired") +
theme_light()
Попробуем еще один способ улучшить точность и интерпретируемость модели – используем UMAP.
|>
base_trained step_umap(all_numeric_predictors(), outcome = "author", num_comp = 7) |>
prep() |>
juice() |>
ggplot(aes(UMAP1, UMAP2, color = author)) +
geom_point(alpha = 0.5) +
theme_light()
Теперь создаем модели для классификайции. Для некоторый вручную прописываем, то они необходимы нам именно для данной задачи.
<- base_rec |>
pls_rec step_pls(all_numeric_predictors(), outcome = "author", num_comp = tune())
<- base_rec |>
umap_rec step_umap(all_numeric_predictors(),
outcome = "author",
num_comp = tune(),
neighbors = tune(),
min_dist = tune()
)
<- multinom_reg(penalty = tune(), mixture = 1) |>
lasso_spec set_mode("classification") |>
set_engine("glmnet")
<- multinom_reg(penalty = tune(), mixture = 0) |>
ridge_spec set_mode("classification") |>
set_engine("glmnet")
<- svm_linear(cost = tune()) |>
svm_spec set_mode("classification") |>
set_engine("LiblineaR")
<- mlp(hidden_units = tune(),
mlp_spec penalty = tune(),
epochs = tune()) |>
set_engine("nnet") |>
set_mode("classification")
<- discrim_flexible(prod_degree = tune()) |>
fda_spec set_engine("earth")
<- nearest_neighbor(neighbors = 5) |>
knn_mod set_engine("kknn") |>
set_mode("classification")
Вносим все в workflow_set для удобного перебора разных рецептов с разными моделями.
<- workflow_set(
wflow_set preproc = list(base = base_rec,
pca = pca_rec,
pls = pls_rec,
umap = umap_rec),
models = list(svm = svm_spec,
lasso = lasso_spec,
ridge = ridge_spec,
mlp = mlp_spec,
fda = fda_spec,
knn = knn_mod),
cross = TRUE
)
plan(multisession, workers = 5)
Переходим к выбору наиболее точной модели. По графику вижно, что больше всего подходит модель base_mlp, однако на этапе интерпретации результатов и визуализации, я столкнулась с проблемой того, что функция tidy не воспринимает объект класса nnet.formula. В таком случае попробуем интерпретировать результаты модели с рангом 2 – base_ridge. Можно также заметить, что размах без учета выбросов у этой модели меньше, чем у base_mlp.
Визуализируем результаты работы модели.
<- train_res |>
ridge_res extract_workflow("base_ridge") |>
finalize_workflow(best_results) |>
last_fit(split = data_split, metrics = metric_set(f_meas, accuracy, roc_auc))
collect_metrics(ridge_res) |>
print()
collect_predictions(ridge_res) |>
roc_curve(truth = author, .pred_Austen:.pred_Trollope) |>
ggplot(aes(1 - specificity, sensitivity, color = .level)) +
geom_abline(slope = 1, color = "gray50", lty = 2, alpha = 0.8) +
geom_path(linewidth = 1.5, alpha = 0.7) +
labs(color = NULL) +
theme_light()
Перейдем к интерпретации модели. Достанем самые отличающие авторов друг от друга слова и визуализируем.
<- extract_fit_parsnip(ridge_res)
final_model
<- broom::tidy(final_model) |>
top_terms filter(term != "(Intercept)") |>
group_by(class) |>
slice_max(abs(estimate), n = 7) |>
ungroup() |>
mutate(term = fct_reorder(term, abs(estimate)))
print(top_terms)
|>
top_terms ggplot(aes(x = estimate, y = term, fill = class)) +
geom_col(show.legend = FALSE, alpha = 0.85) +
facet_wrap(~ class, scales = "free_y", nrow = 4) +
scale_fill_brewer(palette = "Dark2") +
labs(
title = "Наиболее важные признаки для каждого автора",
x = "Коэффициент",
y = "Признак"
+
) theme_minimal()
Сразу обратим внимание на отсутствие имен (в корпусе по русскому канону в итоговой визуализации встретились и Григорий, и Самгин). Значит в этот раз, при куллинге, мы скорее всего все сделали правильно.
Попробуем посмотреть интерпретировать получившееся. Из всех захотелось выделить только слово “very” у Остен и Элиота. Если первая отличается частым использованием, то у второй – наоборот. Остальные слова могут сказать что-то скорее о синтаксисе предложений, чем о смысловых особенностях.