knitr::opts_chunk$set(
echo = TRUE,
message = FALSE,
warning = FALSE,
fig.width = 9,
fig.height = 6
)
В этой работе я анализирую корпус A Small Collection of British Fiction и строю модель, которая определяет автора фрагмента текста. Это задача многоклассовой классификации: классов больше двух, потому что в корпусе представлены разные авторы британской прозы XVIII-XIX веков.
Авторский стиль можно описать через частотность слов. Поэтому я беру тексты, делю их на равные фрагменты по 2000 слов, затем считаю частотности 500 самых частотных слов и использую эти признаки для классификации.
library(tidyverse)
library(textrecipes)
library(tidymodels)
library(tidytext)
library(stylo)
library(future)
Сначала загружаю таблицу с метаданными. В файле есть авторы, названия произведений, годы первой публикации и дополнительные комментарии.
overview <- read_tsv(
"data/overview.tsv",
col_names = c(
"textID", "author", "authorID", "title",
"first_published", "author_gender", "comment"
),
skip = 1
)
overview
## # A tibble: 27 × 7
## textID author authorID title first_published author_gender comment
## <dbl> <chr> <chr> <chr> <dbl> <dbl> <chr>
## 1 1 Austen, Jane JA Emma 1815 1 <NA>
## 2 2 Austen, Jane JA Pride 1813 1 <NA>
## 3 3 Austen, Jane JA Sense 1811 1 <NA>
## 4 4 Bronte, Anne AB Agne… 1847 1 <NA>
## 5 5 Bronte, Anne AB Tent… 1848 1 <NA>
## 6 6 Bronte, Charlotte CB Jane… 1847 1 <NA>
## 7 7 Bronte, Charlotte CB Prof… 1845 1 date o…
## 8 8 Bronte, Charlotte CB Vill… 1853 1 <NA>
## 9 9 Bronte, Emily EB Wuth… 1847 1 <NA>
## 10 10 Dickens, Charles CD Blea… 1852 2 serial
## # ℹ 17 more rows
Посмотрим, сколько произведений приходится на каждого автора.
overview |>
count(author) |>
arrange(n)
## # A tibble: 11 × 2
## author n
## <chr> <int>
## 1 Bronte, Emily 1
## 2 Bronte, Anne 2
## 3 Fielding, Henry 2
## 4 Richardson, Samuel 2
## 5 Sterne, Laurence 2
## 6 Austen, Jane 3
## 7 Bronte, Charlotte 3
## 8 Dickens, Charles 3
## 9 Eliot, George 3
## 10 Thackeray, William Makepeace 3
## 11 Trollope, Antony 3
overview |>
count(author) |>
ggplot(aes(reorder(author, n), n, fill = author)) +
geom_col(show.legend = FALSE) +
xlab(NULL) +
ylab("Number of novels") +
scale_fill_viridis_d() +
theme_light() +
coord_flip()
В корпусе есть авторы с одним, двумя и тремя произведениями. Для интерпретации это нужно учитывать: если у автора меньше исходных текстов, модель все равно получает много учебных примеров после деления романов на фрагменты.
Также посмотрим, как тексты распределены по времени.
overview |>
mutate(author_gender = as.factor(author_gender)) |>
ggplot(aes(first_published, reorder(author, first_published), color = author_gender)) +
geom_point(size = 3) +
xlab("Year of first publication") +
ylab(NULL) +
labs(color = "Gender code") +
theme_light()
Корпус охватывает большой период: от Генри Филдинга и Самюэля Ричардсона в XVIII веке до авторов викторианской прозы XIX века. Поэтому модель может частично улавливать не только индивидуальный стиль, но и различия между литературными периодами.
Загружаю тексты через пакет {stylo}. Затем делю каждый
роман на фрагменты по 2000 слов. Это нужно для того, чтобы модель
обучалась не на целых романах, а на большом количестве сопоставимых
фрагментов.
corpus <- load.corpus.and.parse(corpus.dir = "data/british_fiction/british_fiction")
corpus_samples <- make.samples(
corpus,
sample.size = 2000,
sampling = "normal.sampling",
sample.overlap = 0,
sampling.with.replacement = FALSE
)
corpus_samples_clean <- corpus_samples
Для признаков я выбираю 500 самых частотных слов. Такой подход хорошо подходит для стилометрии: частые слова часто включают служебную лексику и устойчивые авторские привычки, а не только имена персонажей или темы произведений.
mfw <- make.frequency.list(corpus_samples_clean)[1:500]
corpus_tf <- stylo::make.table.of.frequencies(corpus_samples_clean, mfw) |>
as.data.frame.matrix() |>
rownames_to_column("id") |>
as_tibble()
corpus_tf <- corpus_tf |>
separate(id, into = c("author", "title", NA), sep = "_")
corpus_tf
## # A tibble: 3,253 × 502
## author title the and to of i a `in` that he it
## <chr> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 ABronte Agnes 5.4 4.85 3.55 3.1 1 1.4 1.5 0.9 1.25 0.65
## 2 ABronte Agnes 3.2 3.85 4.65 1.95 3.2 2.35 1.2 0.9 0.15 1.05
## 3 ABronte Agnes 4.7 4.4 2.7 1.85 3.75 2.45 1.3 0.8 0.25 1
## 4 ABronte Agnes 3.05 4.5 3.8 2 3.3 2.1 1.1 1.2 1.7 1.7
## 5 ABronte Agnes 4.15 4.1 4.8 2.35 3.55 1.5 2.35 0.7 1.05 1.2
## 6 ABronte Agnes 3.5 3.55 3.5 2.5 4 1.7 1 1.15 0.1 0.85
## 7 ABronte Agnes 4.3 4.65 2.9 2.5 3.3 1.6 2.15 1.2 0.5 0.9
## 8 ABronte Agnes 4.15 3.55 2.85 2.65 2.5 2.3 1.3 1.1 1.7 1
## 9 ABronte Agnes 2.55 3.55 3.75 1.9 3.95 1.15 1.3 0.95 0.35 1.25
## 10 ABronte Agnes 4.4 4.6 3.15 2.8 3.25 2.8 1.6 1.1 0.1 0.7
## # ℹ 3,243 more rows
## # ℹ 490 more variables: you <dbl>, was <dbl>, her <dbl>, his <dbl>, as <dbl>,
## # my <dbl>, `for` <dbl>, not <dbl>, she <dbl>, with <dbl>, had <dbl>,
## # be <dbl>, but <dbl>, have <dbl>, me <dbl>, is <dbl>, at <dbl>, s <dbl>,
## # him <dbl>, so <dbl>, on <dbl>, said <dbl>, this <dbl>, which <dbl>,
## # by <dbl>, all <dbl>, mr <dbl>, would <dbl>, `if` <dbl>, from <dbl>,
## # will <dbl>, what <dbl>, your <dbl>, no <dbl>, or <dbl>, when <dbl>, …
После деления на фрагменты количество наблюдений сильно увеличилось. Проверим, сколько фрагментов есть у каждого автора.
corpus_tf |>
count(author) |>
arrange(n)
## # A tibble: 11 × 2
## author n
## <chr> <int>
## 1 EBronte 59
## 2 Sterne 115
## 3 ABronte 118
## 4 Austen 201
## 5 CBronte 236
## 6 Fielding 244
## 7 Trollope 377
## 8 Eliot 379
## 9 Thackeray 401
## 10 Dickens 414
## 11 Richardson 709
corpus_tf |>
count(author) |>
ggplot(aes(reorder(author, n), n, fill = author)) +
geom_col(show.legend = FALSE) +
xlab(NULL) +
ylab("Number of 2000-word samples") +
scale_fill_viridis_d() +
theme_light() +
coord_flip()
Больше всего фрагментов у Ричардсона, потому что его тексты в корпусе самые длинные. Меньше всего фрагментов у Эмили Бронте, так как в корпусе представлен только один ее роман. Это может влиять на качество классификации по отдельным авторам.
Чтобы посмотреть на структуру данных, применяю PCA. Это не модель классификации, а способ увидеть многомерные данные на двумерном графике.
corpus_top <- corpus_tf |>
add_count(author) |>
filter(n > 5) |>
select(-n, -title)
set.seed(06042025)
data_split <- corpus_top |>
mutate(author = as.factor(author)) |>
initial_split(strata = author)
data_train <- training(data_split)
data_test <- testing(data_split)
set.seed(06042025)
folds <- vfold_cv(data_train, strata = author, v = 5)
base_rec <- recipe(author ~ ., data = data_train) |>
step_zv(all_predictors()) |>
step_normalize(all_predictors())
pca_rec <- base_rec |>
step_pca(all_predictors(), num_comp = 7)
pca_trained <- pca_rec |>
prep(data_train)
pca_trained |>
juice() |>
ggplot(aes(PC1, PC2, color = author)) +
geom_point(alpha = 0.7) +
theme_light()
На графике видно, что авторы частично пересекаются, но некоторые группы все же образуют собственные области. Значит, частотности слов дают информацию об авторском стиле.
Дальше я использую {tidymodels}. Для сравнения беру
четыре модели из урока:
Каждую модель проверяю в двух вариантах: с исходными частотными признаками и с PCA-признаками.
ridge_spec <- multinom_reg(penalty = tune(), mixture = 0) |>
set_mode("classification") |>
set_engine("glmnet")
lasso_spec <- multinom_reg(penalty = tune(), mixture = 1) |>
set_mode("classification") |>
set_engine("glmnet")
svm_spec <- svm_linear(cost = tune()) |>
set_mode("classification") |>
set_engine("LiblineaR")
knn_spec <- nearest_neighbor(neighbors = 5) |>
set_engine("kknn") |>
set_mode("classification")
wflow_set <- workflow_set(
preproc = list(
base = base_rec,
pca = pca_rec
),
models = list(
ridge = ridge_spec,
lasso = lasso_spec,
svm = svm_spec,
knn = knn_spec
),
cross = TRUE
)
wflow_set
## # A workflow set/tibble: 8 × 4
## wflow_id info option result
## <chr> <list> <list> <list>
## 1 base_ridge <tibble [1 × 4]> <opts[0]> <list [0]>
## 2 base_lasso <tibble [1 × 4]> <opts[0]> <list [0]>
## 3 base_svm <tibble [1 × 4]> <opts[0]> <list [0]>
## 4 base_knn <tibble [1 × 4]> <opts[0]> <list [0]>
## 5 pca_ridge <tibble [1 × 4]> <opts[0]> <list [0]>
## 6 pca_lasso <tibble [1 × 4]> <opts[0]> <list [0]>
## 7 pca_svm <tibble [1 × 4]> <opts[0]> <list [0]>
## 8 pca_knn <tibble [1 × 4]> <opts[0]> <list [0]>
Для оценки моделей использую 5-фолдную кросс-валидацию. Метрики:
accuracy и f_meas.
plan(sequential)
set.seed(180525)
train_res <- wflow_set |>
workflow_map(
verbose = TRUE,
seed = 180525,
resamples = folds,
grid = 3,
metrics = metric_set(f_meas, accuracy),
control = control_resamples(save_pred = TRUE)
)
autoplot(train_res, metric = "accuracy") +
theme_light() +
theme(legend.position = "none") +
geom_text(
aes(y = mean - 2 * std_err, label = wflow_id),
angle = 90,
hjust = 1.5
) +
coord_cartesian(ylim = c(-0.3, NA))
rank_results(train_res, select_best = TRUE) |>
print()
## # A tibble: 16 × 9
## wflow_id .config .metric mean std_err n preprocessor model rank
## <chr> <chr> <chr> <dbl> <dbl> <int> <chr> <chr> <int>
## 1 base_ridge pre0_mod1_po… accura… 0.997 0.00104 5 recipe mult… 1
## 2 base_ridge pre0_mod1_po… f_meas 0.994 0.00260 5 recipe mult… 1
## 3 base_svm pre0_mod1_po… accura… 0.993 0.00211 5 recipe svm_… 2
## 4 base_svm pre0_mod1_po… f_meas 0.988 0.00378 5 recipe svm_… 2
## 5 base_lasso pre0_mod1_po… accura… 0.990 0.00173 5 recipe mult… 3
## 6 base_lasso pre0_mod1_po… f_meas 0.984 0.00580 5 recipe mult… 3
## 7 pca_ridge pre0_mod1_po… accura… 0.864 0.00746 5 recipe mult… 4
## 8 pca_ridge pre0_mod1_po… f_meas 0.884 0.00639 5 recipe mult… 4
## 9 base_knn pre0_mod0_po… accura… 0.867 0.00764 5 recipe near… 5
## 10 base_knn pre0_mod0_po… f_meas 0.827 0.00791 5 recipe near… 5
## 11 pca_knn pre0_mod0_po… accura… 0.879 0.00372 5 recipe near… 6
## 12 pca_knn pre0_mod0_po… f_meas 0.814 0.00634 5 recipe near… 6
## 13 pca_svm pre0_mod1_po… accura… 0.880 0.00713 5 recipe svm_… 7
## 14 pca_svm pre0_mod1_po… f_meas 0.808 0.0184 5 recipe svm_… 7
## 15 pca_lasso pre0_mod1_po… accura… 0.895 0.00140 5 recipe mult… 8
## 16 pca_lasso pre0_mod1_po… f_meas 0.808 0.0100 5 recipe mult… 8
Лучше всего работает модель base_ridge, то есть
ridge-регрессия на исходных частотных признаках. В данных много
связанных между собой частотных признаков, а регуляризация помогает
модели не переобучаться.
PCA не улучшает лучшие модели. Вероятно, при сокращении пространства до 7 компонент теряется часть информации, которая была полезна для точного различения авторов.
autoplot(train_res, id = "base_ridge") +
theme_light()
Финализирую лучшую модель и проверяю ее на тестовой выборке, которая не участвовала в обучении.
best_results <- train_res |>
extract_workflow_set_result("base_ridge") |>
select_best(metric = "accuracy")
print(best_results)
## # A tibble: 1 × 2
## penalty .config
## <dbl> <chr>
## 1 1.07e-10 pre0_mod1_post0
ridge_res <- train_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()
## # A tibble: 3 × 4
## .metric .estimator .estimate .config
## <chr> <chr> <dbl> <chr>
## 1 f_meas macro 0.995 pre0_mod0_post0
## 2 accuracy multiclass 0.996 pre0_mod0_post0
## 3 roc_auc hand_till 1.000 pre0_mod0_post0
Результат на тестовой выборке получился высоким. Частотные признаки хорошо подходят для задачи авторской атрибуции в этом корпусе.
collect_predictions(ridge_res) |>
conf_mat(truth = author, estimate = .pred_class) |>
autoplot(type = "heatmap") +
scale_fill_gradient(low = "white", high = "#233857") +
theme(
panel.grid.major = element_line(colour = "#233857"),
axis.text = element_text(color = "#233857"),
axis.title = element_text(color = "#233857"),
plot.title = element_text(color = "#233857"),
axis.text.x = element_text(angle = 90)
)
По матрице ошибок видно, что модель почти всегда правильно определяет автора. Ошибок мало, поэтому основные значения находятся на диагонали.
collect_predictions(ridge_res) |>
roc_curve(truth = author, .pred_ABronte:.pred_Trollope) |>
ggplot(aes(1 - specificity, sensitivity, color = .level)) +
geom_abline(slope = 1, color = "gray50", lty = 2, alpha = 0.8) +
geom_path(linewidth = 1.2, alpha = 0.7) +
labs(color = NULL) +
theme_light()
ROC-кривые также говорят о хорошем качестве модели: линии проходят близко к верхнему левому углу.
Теперь посмотрим, какие слова сильнее всего связаны с каждым автором в финальной модели. Для этого беру коэффициенты ridge-регрессии.
final_model <- extract_fit_parsnip(ridge_res)
top_terms <- tidy(final_model) |>
filter(term != "(Intercept)") |>
group_by(class) |>
slice_max(abs(estimate), n = 10) |>
ungroup() |>
mutate(term = fct_reorder(term, abs(estimate)))
top_terms |>
print()
## # A tibble: 110 × 4
## class term estimate penalty
## <chr> <fct> <dbl> <dbl>
## 1 ABronte but 0.221 1.07e-10
## 2 ABronte and 0.131 1.07e-10
## 3 ABronte or 0.119 1.07e-10
## 4 ABronte replied 0.109 1.07e-10
## 5 ABronte arthur 0.102 1.07e-10
## 6 ABronte which -0.0995 1.07e-10
## 7 ABronte too 0.0969 1.07e-10
## 8 ABronte them 0.0936 1.07e-10
## 9 ABronte few 0.0902 1.07e-10
## 10 ABronte out -0.0899 1.07e-10
## # ℹ 100 more rows
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 = "Paired") +
labs(
title = "Most important features for each author",
x = "Coefficient",
y = "Feature"
) +
theme_minimal()
Среди важных признаков есть не только содержательные слова, но и частые служебные слова. Для стилометрии это ожидаемо: модель часто различает авторов по частотным привычкам, а не только по темам и именам персонажей.
В этой работе я построил модель многоклассовой классификации для корпуса британской прозы. Сначала тексты были разделены на равные фрагменты, затем для каждого фрагмента были посчитаны частоты 500 самых частотных слов. Такой дизайн признаков подходит для анализа авторского стиля, потому что частотные слова хорошо отражают устойчивые речевые привычки.
Разведывательный анализ показал, что корпус несбалансирован: у разных авторов разное число произведений и фрагментов. Особенно много фрагментов у Ричардсона, а меньше всего у Эмили Бронте. PCA показал частичное разделение авторов, но также заметное пересечение между некоторыми группами.
По итогам кросс-валидации лучшей оказалась ridge-регрессия с исходными частотными признаками. Модели с PCA в целом уступали лучшим моделям без PCA, потому что при снижении размерности часть различающей информации, вероятно, терялась.
Финальная модель показала высокое качество на тестовой выборке. Даже простые частотные признаки могут хорошо работать для задачи авторской атрибуции, если тексты достаточно длинные и корпус подготовлен одинаково.