library(tidyverse)
library(tidymodels)
library(textrecipes)
library(tidytext)
library(stylo)
library(embed)
library(baguette)
library(discrim)
library(ggrepel)
library(learntidymodels)
corpus <- load.corpus.and.parse(corpus.dir = "british_fiction")
#прочитать метаданные
##разделяем тексты на отрывки длиной в 2000 слов
corpus_samples <- make.samples(corpus,
sample.size = 2000,
sampling = "normal.sampling",
sample.overlap = 0,
sampling.with.replacement = FALSE)
#perform.culling(culling.level = 20) #почитать про аргумент и подумать добавлять или нет
##приводим к нижнему регистру, убираем ’s
corpus_clean <- map(corpus_samples, function(text) {
str_to_lower(str_remove_all(text, "[''`]s\\b"))
})
#подумать сотавлять или нет нижний регистр, убирать ли имена потому что имена по идее убирает perform.culling
# 27.2 подготовка датасета
##500 самых частотных слов
mfw <- make.frequency.list(corpus_clean)[1:500]
# частотная матрица
corpus_tf <- stylo::make.table.of.frequencies(corpus_clean, mfw) |>
as.data.frame.matrix() |>
rownames_to_column("id") |>
as_tibble()
# разбираем id вида Author_Book_sampleN на части
corpus_tf <- corpus_tf |>
separate(id, into = c("author", "book", "sample"), sep = "_",
extra = "merge", fill = "right", remove = FALSE)
corpus_tf |>
select(id, author, book, sample) |>
head()
## # A tibble: 6 × 4
## id author book sample
## <chr> <chr> <chr> <chr>
## 1 ABronte_Agnes_1 ABronte Agnes 1
## 2 ABronte_Agnes_2 ABronte Agnes 2
## 3 ABronte_Agnes_3 ABronte Agnes 3
## 4 ABronte_Agnes_4 ABronte Agnes 4
## 5 ABronte_Agnes_5 ABronte Agnes 5
## 6 ABronte_Agnes_6 ABronte Agnes 6
#Особенности корпуса: сколько отрывков на каждого автора. На графике отражен сильный дисбаланс:
#Richardson представлен 700 сэмплами, а EBronte ттолько 60 — это её единственный роман
# «Грозовой перевал». Из-за дисбаланса модели обучаются на
# немногих фрагментах, поэтому выборки разбиваются со стратификацией по автору,
# а качество оценивается в том числе по f-мере, устойчивой к неравному числу классов.
corpus_tf |>
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_top <- corpus_tf |>
add_count(author) |>
filter(n > 100) |>
select(-n, -id, -book, -sample) |>
mutate(author = as.factor(author))
#Визуализиуем
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()
#обучающая и тестовые выборки
set.seed(30052026)
data_split <- corpus_top |>
initial_split(strata = author)
data_train <- training(data_split)
data_test <- testing(data_split)
# folds
set.seed(30052026)
folds <- vfold_cv(data_train, strata = author, v = 5)
folds
## # 5-fold cross-validation using stratification
## # A tibble: 5 × 2
## splits id
## <list> <chr>
## 1 <split [1912/481]> Fold1
## 2 <split [1914/479]> Fold2
## 3 <split [1915/478]> Fold3
## 4 <split [1915/478]> Fold4
## 5 <split [1916/477]> Fold5
#при prep и bake подумать над количеством компонент (зависит от количества авторов в корпусе) num_comp =7 /8/10
base_rec <- recipe(author ~ ., data = data_train) |>
step_zv(all_predictors()) |>
step_normalize(all_predictors())
base_rec
#рецепт, в котором используем главные компоненты в качестве предикторов
pca_rec <- base_rec |>
step_pca(all_predictors(), num_comp = 11)
pca_rec
base_trained <- base_rec |>
prep(data_train)
base_trained
base_trained |>
bake(new_data = NULL)
## # A tibble: 2,393 × 500
## the and to of i a `in` that he
## <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 0.0968 2.03 -0.154 0.807 -0.0322 1.01 -0.0774 -1.61 -1.24
## 2 -1.33 -0.143 0.761 -2.22 2.33 -0.635 -2.27 -0.693 1.48
## 3 -0.975 -0.0153 0.339 -0.745 0.0406 -0.828 -0.335 -0.287 0.551
## 4 -0.499 1.84 0.550 -0.0801 0.877 0.620 -1.11 0.119 -1.51
## 5 -0.0620 0.561 -0.0132 0.142 2.01 -0.152 -2.01 -0.896 1.21
## 6 1.37 3.50 -1.21 0.216 -0.796 1.78 -0.851 -1.61 -0.910
## 7 0.256 1.01 2.17 -0.302 0.332 -0.828 -1.11 0.423 -0.578
## 8 -0.975 0.625 -0.576 -1.26 1.46 -0.924 -0.980 -0.592 -0.246
## 9 -0.657 0.497 -0.0836 -1.26 1.86 -1.70 -0.464 -0.693 -1.44
## 10 -0.896 1.52 0.339 -0.00618 0.695 -1.21 -0.722 -0.0843 -0.0472
## # ℹ 2,383 more rows
## # ℹ 491 more variables: it <dbl>, 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>, …
#методы снижения размерности
pca_trained <- pca_rec |>
prep(data_train)
pca_trained |>
juice()
## # A tibble: 2,393 × 12
## author PC01 PC02 PC03 PC04 PC05 PC06 PC07 PC08 PC09 PC10
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 ABron… -0.589 2.64 0.824 -4.58 0.215 1.68 -3.27 0.748 1.28 -1.28
## 2 ABron… -0.131 -8.21 -1.54 1.76 1.56 1.63 -1.41 -4.37 1.63 -1.84
## 3 ABron… 0.499 -1.29 3.34 -2.04 -1.31 2.26 -2.80 -0.123 2.40 -1.19
## 4 ABron… -0.0798 -0.246 -0.665 -2.52 -3.29 3.39 -2.45 0.856 0.632 -1.52
## 5 ABron… -1.54 -4.47 1.27 -1.28 -1.67 2.55 2.08 -0.729 2.94 -1.44
## 6 ABron… -5.44 5.43 -3.54 -2.82 1.71 1.11 -0.915 2.08 1.27 -3.79
## 7 ABron… 4.04 -1.13 0.813 1.20 2.90 2.12 -1.20 0.605 0.933 -0.974
## 8 ABron… 0.634 -7.52 -0.844 -0.482 -0.850 1.95 1.18 1.59 -2.49 -1.09
## 9 ABron… -0.0939 -6.90 -2.15 -7.67 0.208 1.26 3.16 1.91 -0.765 -2.69
## 10 ABron… 2.52 -1.71 1.80 -1.14 -0.112 3.17 0.132 -0.0150 0.773 -3.26
## # ℹ 2,383 more rows
## # ℹ 1 more variable: PC11 <dbl>
pca_trained |> juice() |> names()
## [1] "author" "PC01" "PC02" "PC03" "PC04" "PC05" "PC06" "PC07"
## [9] "PC08" "PC09" "PC10" "PC11"
#визуализация
pca_trained |>
juice() |>
ggplot(aes(PC01, PC02, color = author)) +
geom_point(alpha = 0.7, size = 2) +
scale_color_brewer(palette = "Paired") +
theme_light()
#Вывод.
pca_trained |>
juice() |>
group_by(author) |>
summarise(PC01 = mean(PC01)) |>
arrange(PC01)
## # A tibble: 10 × 2
## author PC01
## <fct> <dbl>
## 1 Thackeray -4.76
## 2 Eliot -3.85
## 3 Dickens -3.28
## 4 CBronte -2.88
## 5 Trollope -1.21
## 6 Sterne -0.145
## 7 ABronte 0.269
## 8 Fielding 1.59
## 9 Austen 1.59
## 10 Richardson 7.26
#нагрузки компонент
pca_trained |>
plot_top_loadings(component_number <= 4, n = 10) +
scale_fill_brewer(palette = "Paired") +
theme_light()
# ### Нагрузки компонент
#
График показывает, какие слова больше всего «весят» в каждой главной компоненте. К сожалению, на графики попал и мусор, но это не мешает увидеть, что во всех четырёх компонентах доминируют служебные слова, а не имена или сюжетная лексика — в какой-то степени это подтверждает, что снижение размерности здесь опирается именно на стилистическую компоненту.
#
PC1 нагружают модальные и вспомогательные глаголы (will, have, be, may, cannot) и местоимение your — это лексика инструкции, обещания, прямого обращения, характерная для эпистолярной прозы; именно она и выделяет Ричардсона — мастера скорее эпистолярного жанра. PC2 строится на of, which, the, you, said, know, do — оппозиция «книжной» синтаксической связности (of, which, the) и устной диалоговой речи (you, said, know). PC3 (that, been, had, course, certainly, himself, would) и PC4 (seemed, could, she, who, her, felt) разграничивают повествовательное время и фокус: PC4 с её she/her/felt тяготеет, возможно, к женским образам и внутренней жизни персонажей. Как когда-то в куртуазном романе для dame стало больше места, чем в эпосе (“Песнь о Роланде”, в которой невесте Роланда Альде посвящены два стиха), так и в романах Ричардсона для женщины расширяется пространство - почти впервые в английской традиции.
# install.packages("BiocManager")
# BiocManager::install("mixOmics")
pls_trained <- base_trained |>
step_pls(all_numeric_predictors(), outcome = "author", num_comp = 11) |>
prep()
pls_trained |>
juice()
## # A tibble: 2,393 × 12
## author PLS01 PLS02 PLS03 PLS04 PLS05 PLS06 PLS07 PLS08 PLS09
## <fct> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 ABronte 0.255 0.585 -0.320 -3.74 -1.88 -2.07 3.23 0.830 0.442
## 2 ABronte 0.570 2.52 -5.13 0.134 1.46 -0.0164 0.786 0.0879 -6.46
## 3 ABronte -0.0750 3.79 -0.0875 -1.92 -3.46 -1.28 2.39 0.103 -3.16
## 4 ABronte -0.0000960 -0.263 -0.858 -2.58 -3.85 -2.21 2.38 0.316 -4.08
## 5 ABronte 1.99 2.76 -2.17 -1.91 -2.42 2.28 1.16 0.722 -4.73
## 6 ABronte 4.21 -4.98 0.0952 -3.13 -0.582 -0.628 3.75 3.63 -0.527
## 7 ABronte -3.20 1.56 -0.945 -0.986 1.12 -0.369 1.06 0.111 -4.21
## 8 ABronte 0.0787 2.23 -4.98 -0.644 -2.15 0.284 -1.45 1.98 -4.51
## 9 ABronte 0.244 1.96 -6.37 -4.69 -2.97 2.38 1.89 3.59 -4.49
## 10 ABronte -1.69 2.14 -0.318 -2.24 -1.32 -0.145 0.204 1.27 -3.73
## # ℹ 2,383 more rows
## # ℹ 2 more variables: PLS10 <dbl>, PLS11 <dbl>
#визуализация
pls_trained |>
juice() |>
ggplot(aes(PLS01, PLS02, color = author)) +
geom_point() +
theme_light()
PLS, в отличие от PCA, — метод обучения с учителем: при построении компонент он использует метки авторов и ищет направления, которые именно РАЗДЕЛЯЮТ классы, насколько понимаю, а не просто описывают общую дисперсию. Результат нагляднее PCA: авторы образуют более различимые зоны (хотя это все еще не самый лучший резульат, очевидно). Ричардсон (голубой) уходит плотным облаком влево, снова обособляясь сильнее всех — его эпистолярный стиль выделяется при любом методе. Троллоп (вверху) и Теккерей (внизу справа) занимают противоположные концы по PLS02. Дж.Остин (оранжевый) собирается отдельным сгустком вверху-слева. Показательно положение Филдинга (бирюзовый): он лежит в переходной зоне между Ричардсоном и викторианцами — оба автора относятся к XVIII веку, и PLS, возможно, улавливает эту хронологическую близость.
“Перекрытие” сохраняется в правом центре, где сходятся викторианские романисты (Диккенс, Элио, сёстры Бронте) — их стили наиболее близки. Но в целом границы между авторами здесь видны куда лучше, чем в PCA.
#нагрузки компонент
pls_trained |>
plot_top_loadings(component_number <= 4, n = 10, type = "pls") +
scale_fill_brewer(palette = "Paired") +
theme_light()
library(embed)
base_trained |>
step_umap(all_numeric_predictors(), outcome = "author", num_comp = 4) |>
prep() |>
juice() |>
ggplot(aes(UMAP1, UMAP2, color = author)) +
geom_point(alpha = 0.5) +
theme_light()
UMAP разделяет лучше всех.
pls_rec <- base_rec |>
step_pls(all_numeric_predictors(), outcome = "author", num_comp = tune())
umap_rec <- base_rec |>
step_umap(all_numeric_predictors(),
outcome = "author",
num_comp = tune(),
neighbors = tune(),
min_dist = tune())
lasso_spec <- multinom_reg(penalty = tune(), mixture = 1) |>
set_mode("classification") |>
set_engine("glmnet")
ridge_spec <- multinom_reg(penalty = tune(), mixture = 0) |>
set_mode("classification") |>
set_engine("glmnet")
svm_spec <- svm_linear(cost = tune()) |>
set_mode("classification") |>
set_engine("LiblineaR")
svm_spec
## Linear Support Vector Machine Model Specification (classification)
##
## Main Arguments:
## cost = tune()
##
## Computational engine: LiblineaR
mlp_spec <- mlp(hidden_units = tune(),
penalty = tune(),
epochs = tune()) |>
set_engine("nnet") |>
set_mode("classification")
bagging_spec <- bag_tree() |>
set_engine("rpart") |>
set_mode("classification")
fda_spec <- discrim_flexible(prod_degree = tune()) |>
set_engine("earth")
rda_spec <- discrim_regularized(frac_common_cov = tune(),
frac_identity = tune()) |>
set_engine('klaR')
# devtools::install_github("KlausVigo/kknn")
knn_spec <- nearest_neighbor(neighbors = 5) |>
set_engine("kknn") |>
set_mode("classification")
library(baguette) # для бэггинга
library(discrim) # для fda_spec и rda_spec
wflow_set <- workflow_set(
preproc = list(base = base_rec, pca = pca_rec),
models = list(ridge = ridge_spec, lasso = lasso_spec, svm = svm_spec),
cross = TRUE
)
wflow_set
## # A workflow set/tibble: 6 × 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 pca_ridge <tibble [1 × 4]> <opts[0]> <list [0]>
## 5 pca_lasso <tibble [1 × 4]> <opts[0]> <list [0]>
## 6 pca_svm <tibble [1 × 4]> <opts[0]> <list [0]>
pca_rec <- base_rec |> step_pca(all_predictors(), num_comp = tune())
library(future)
plan(multisession, workers = 6)
plan(sequential)
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)
)
plan(sequential)
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: 12 × 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.998 6.63e-4 5 recipe mult… 1
## 2 base_ridge pre0_mod1_po… f_meas 0.997 1.06e-3 5 recipe mult… 1
## 3 base_svm pre0_mod1_po… accura… 0.995 5.17e-4 5 recipe svm_… 2
## 4 base_svm pre0_mod1_po… f_meas 0.994 1.04e-3 5 recipe svm_… 2
## 5 base_lasso pre0_mod1_po… accura… 0.990 2.03e-3 5 recipe mult… 3
## 6 base_lasso pre0_mod1_po… f_meas 0.988 2.37e-3 5 recipe mult… 3
## 7 pca_lasso pre0_mod1_po… accura… 0.957 3.55e-3 5 recipe mult… 4
## 8 pca_lasso pre0_mod1_po… f_meas 0.940 4.44e-3 5 recipe mult… 4
## 9 pca_svm pre0_mod2_po… accura… 0.950 2.19e-3 5 recipe svm_… 5
## 10 pca_svm pre0_mod2_po… f_meas 0.917 6.97e-3 5 recipe svm_… 5
## 11 pca_ridge pre0_mod1_po… accura… 0.935 4.54e-3 5 recipe mult… 6
## 12 pca_ridge pre0_mod1_po… f_meas 0.903 2.12e-2 5 recipe mult… 6
autoplot(train_res, id = "base_ridge") +
theme_light()
best_results <-
train_res |>
extract_workflow_set_result("base_svm") |>
select_best(metric = "accuracy")
print(best_results)
## # A tibble: 1 × 2
## cost .config
## <dbl> <chr>
## 1 0.00101 pre0_mod1_post0
best_results <- train_res |>
extract_workflow_set_result("base_svm") |>
select_best(metric = "accuracy")
print(best_results)
## # A tibble: 1 × 2
## cost .config
## <dbl> <chr>
## 1 0.00101 pre0_mod1_post0
best_results <- train_res |>
extract_workflow_set_result("base_ridge") |>
select_best(metric = "accuracy")
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 1 pre0_mod0_post0
## 2 accuracy multiclass 1 pre0_mod0_post0
## 3 roc_auc hand_till 1 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) |>
select(starts_with(".pred_")) |>
names() #можно оставить, а можно убрать
## [1] ".pred_class" ".pred_ABronte" ".pred_Austen" ".pred_CBronte"
## [5] ".pred_Dickens" ".pred_Eliot" ".pred_Fielding" ".pred_Richardson"
## [9] ".pred_Sterne" ".pred_Thackeray" ".pred_Trollope"
Матрица практически идеальна: все предсказания лежат на диагонали, вне её — нули. На отложенной выборке модель не ошиблась ни разу, включая близкие пары вроде ABronte / CBronte и стилистически смежных викторианцев. Даже самые малочисленные классы определены верно (Стерн — 27 отрывков, (A)Бронте — 34), тогда как у Ричардсона (174) правильно классифицированы все.
Все это получено при разбиении на train/test по отрывкам, при котором фрагменты одного и того же романа попадают и в обучение, и в тест. Поэтому модель отчасти опознаёт не идиолект, а конкретную книгу — по именам героев и сюжетной лексике (что подтверждается интерпретацией признаков ниже, где у авторов всплывают имена персонажей). Наверное, если бы отрывки одного произведения не делились между выборками, а имена собственные мы бы удалили с помощью culling — тогда точность стала бы скромнее, но честнее отражала бы собственно авторский стиль.
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.5, alpha = 0.7) +
scale_color_brewer(palette = "Paired") +
labs(color = NULL) +
theme_light()
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)))
print(top_terms)
## # A tibble: 100 × 4
## class term estimate penalty
## <chr> <fct> <dbl> <dbl>
## 1 ABronte but 0.203 1.07e-10
## 2 ABronte and 0.149 1.07e-10
## 3 ABronte which -0.117 1.07e-10
## 4 ABronte replied 0.113 1.07e-10
## 5 ABronte or 0.106 1.07e-10
## 6 ABronte in -0.104 1.07e-10
## 7 ABronte out -0.0913 1.07e-10
## 8 ABronte this -0.0840 1.07e-10
## 9 ABronte who -0.0817 1.07e-10
## 10 ABronte alone 0.0784 1.07e-10
## # ℹ 90 more rows
library(tidytext)
top_terms |>
mutate(term = reorder_within(term, abs(estimate), class)) |>
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_y_reordered() +
scale_fill_viridis_d() +
labs(title = "Наиболее важные признаки для каждого автора",
x = "Коэффициент", y = "Признак") +
theme_minimal()
Коэффициенты ridge-модели показывают, какие слова сильнее всего «тянут» отрывок к тому или иному автору.
Первый тип — имена героев, но это совсем не интересно. Это утечка содержания: модель отчасти опознаёт книгу, а не стиль, и именно она обеспечивает почти идеальную точность при разбиении по отрывкам. В строгом исследовании имена собственные следовало бы удалить (через culling).
Второй тип — cтилевые признаки, и они интереснее. У Дж.Остин доминируют every, could, very, however, must — оценочно-модальная лексика, которая относится к free indirect discourse) и которая отличает Остин. У Элиота рядом со служебными словами стоят feeling, felt, sense — лексика внутренней жизни: психологизм и проникновение в сознание героя — определяющая черта её прозы и предмет отдельных готовых исследований. У Филдинга — indeed, therefore, which: риторические коннекторы эссеистической, авторски-комментирующей прозы XVIII века.
Интересно слово eye, находящееся в топе у (C)Bronte. Мотив ВЗГЛЯДА (gaze) — устойчивый предмет критики именно у Шарлотты Бронте, ему посвящена, например, работа о функции взгляда в «Профессоре» и «Джейн Эйр» (2004).
Вывод: ridge-модель опирается одновременно на стиль (модальность, психологизм, метанарративность, риторические связки) и на сюжет (имена). Это объясняет высокую точность, но и показывает её цену — часть «успеха» обеспечена утечкой содержания, а не чистым авторским почерком. ## Внутриавторская вариативность: случай Диккенса
# матрица нормализованных частот слов (только 500 слов, без служебных колонок)
word_cols <- setdiff(names(corpus_tf), c("id", "author", "book", "sample"))
feat_mat <- corpus_tf |>
select(all_of(word_cols)) |>
scale()
meta_rows <- corpus_tf |> select(author, book)
D <- as.matrix(dist(feat_mat)) # евклидовы расстояния между всеми отрывками
# среднее расстояние между двумя наборами строк
mean_dist <- function(idx1, idx2, same = FALSE) {
sub <- D[idx1, idx2, drop = FALSE]
if (same) sub <- sub[upper.tri(sub)]
mean(sub)
}
dick <- which(meta_rows$author == "Dickens")
other <- which(meta_rows$author != "Dickens")
# внутри одного романа (усреднение по трём романам)
within_book <- map_dbl(unique(meta_rows$book[dick]), function(b) {
idx <- which(meta_rows$author == "Dickens" & meta_rows$book == b)
mean_dist(idx, idx, same = TRUE)
}) |> mean()
# между разными романами Диккенса
between_books <- {
bks <- unique(meta_rows$book[dick])
combn(bks, 2, function(pair) {
i1 <- which(meta_rows$author == "Dickens" & meta_rows$book == pair[1])
i2 <- which(meta_rows$author == "Dickens" & meta_rows$book == pair[2])
mean_dist(i1, i2)
}) |> mean()
}
# И еще немного о случае Диккенса
between_authors <- mean_dist(dick, other)
tibble(
тип = c("внутри одного романа (Dickens)",
"между романами Dickens",
"Dickens против других авторов"),
среднее_расстояние = c(within_book, between_books, between_authors)
)
## # A tibble: 3 × 2
## тип среднее_расстояние
## <chr> <dbl>
## 1 внутри одного романа (Dickens) 29.8
## 2 между романами Dickens 30.4
## 3 Dickens против других авторов 31.6
Визуализируем три романа Диккенса в пространстве первых двух главных компонент на фоне остальных авторов серым).
# PCA на всех данных (для разведки, с фиксированным числом компонент)
pca_dick <- corpus_tf |>
select(author, book, all_of(word_cols)) |>
mutate(author = as.factor(author)) |>
recipe(author ~ ., data = _) |>
update_role(book, new_role = "id") |>
step_zv(all_predictors()) |>
step_normalize(all_predictors()) |>
step_pca(all_predictors(), num_comp = 5) |>
prep() |>
juice()
pca_dick |>
mutate(grp = if_else(author == "Dickens", paste0("Dickens: ", book), "другие авторы")) |>
ggplot(aes(PC1, PC2)) +
geom_point(data = ~filter(.x, grp == "другие авторы"),
color = "grey80", size = 1, alpha = 0.5) +
geom_point(data = ~filter(.x, grp != "другие авторы"),
aes(color = grp), size = 1.8, alpha = 0.8) +
stat_ellipse(data = ~filter(.x, grp != "другие авторы"),
aes(color = grp), level = 0.7) +
scale_color_brewer(palette = "Set1") +
labs(title = "Три романа Диккенса в пространстве стиля",
subtitle = "Сливаются ли они в одно облако или расходятся?",
color = NULL) +
theme_light()
Отдельный вопрос — насколько стиль одного автора стабилен от романа к роману. Диккенс “удобен” для проверки: в корпусе три его романа (Bleak House, David Copperfield, Hard Times). М сравнили три типа средних стилевых расстояний между отрывками — внутри одного романа, между разными романами Диккенса и между Диккенсом и другими авторами:
Эти числа говорят сразу о двух вещах. Во-первых, стиль Диккенса стабилен: перейти из одного его романа в другой (29.8 → 30.4) почти ничего не меняет, то есть авторский почерк держится от книи к книге, а не распадается на разные манеры. На PCA-графике это видно прямо: эллипсы трёх романов почти полностью накладываются друг на друга, образуют плотный сгусток, отделённый от остальных авторов.
Во-вторых — и это неожиданнее — расстояние додругих авторов (31.6) лишь немного больше, чем внутри самого Диккенса (29.8). Авторская компонента есть, но она, видимо, слабая: в 500-мерном пространстве частот слов все отрывки расположены почти равноудалённо друг от друга. Это проявление «проклятия размерности»? в пространствах высокой размерности расстояния между точками плохо различают классы. Поэтому успех классификации обеспечивают не сами по себе расстояния, а способность регуляризованных моделей (ridge, SVM) найти подходящие веса в этом пространстве — отдельные маркерные слова, а не общую близость. Этот результат и оправдывает выбор линейных моделей с регуляризацией и объясняет, почему снижение размерности (PCA) их ухудшало: оно стирало как раз те тонкие направления, которые модель использует для разделения.