16  Стилометрический анализ с пакетом stylo

16.1 Установка stylo

В этом уроке мы рассмотрим возможности стилометрического анализа с использованием пакета stylo. К пакету прилагается внятный HOWTO.

library(stylo)

### stylo version: 0.7.5 ###

If you plan to cite this software (please do!), use the following reference:
    Eder, M., Rybicki, J. and Kestemont, M. (2016). Stylometry with R:
    a package for computational text analysis. R Journal 8(1): 107-121.
    <https://journal.r-project.org/archive/2016/RJ-2016-007/index.html>

To get full BibTeX entry, type: citation("stylo")

Возможности этого инструмента мы исследуем на корпусе древнегреческой литературы, подробнее о котором можно прочитать в препринте. Для этого эксперимента корпус был немного урезан и разложен по папкам. Корпус в формате .zip надо забрать по ссылке и сделать рабочей директорией.

Тексты могут быть на любом языке, но обязательно в кодировке Unicode.

На Mac может потребоваться поставить XQuartz.

16.2 stylo()

Главная рабочая лошадка этого пакета – функция stylo(). Если вызвать ее без аргументов, то запустится GUI (который можно отключить).

stylo()

На вкладке Input & Language выбираете формат файла и язык.

На вкладке Features указываете, как разбивать текст: на слова, символы, словесные или символьные энграмы. Также можно уточнить, что делать с прописными буквами (в нашем случае это нерелевантно). Параметр MFW указывает, сколько слов использовать для анализа. CULLING задает порог отсечения для слов: 20 означает, что будут использованы слова, которые встречаются как минимум в 20% текстов, 0 – все слова, 100 - только те, которые есть во всех текстах корпуса.

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

Можно также уточнить метод выборки.

И, наконец, формат, в котором следует вернуть результат.

Без графического интерфейса команда будет выглядеть так.

stylo(corpus.dir = "corpus", 
      analysis.type = "CA",
      analyzed.features="w", 
      ngram.size=1,
      culling.min=20,
      culling.max=20,
      mfw.min = 100,
      mfw.max = 100,
      mfw.incr = 0,
      distance.measure = "dist.delta",
      write.png.file = FALSE,
      gui = FALSE,
      corpus.lang = "Other",
      plot.custom.height=8,
      plot.custom.width=7
      )

Подписи и цвета функция подбирает автоматически. Попробуйте использовать другие меры расстояния и другие статистические методы, и сравните результат (пока можно не обращать внимания на Consensus Tree – об этом поговорим на следующем занятии). После каждого запуска функции в рабочей директории сохраняются файлы с конфигурацией, признаками, которые использовались для анализа и, опционально, визуализация.

stylo(corpus.dir = "corpus", 
      analysis.type = "PCR",
      analyzed.features="w", 
      ngram.size=1,
      mfw.min = 100,
      mfw.max = 100,
      mfw.incr = 0,
      #pca.visual.flavour="loadings",
      write.png.file = FALSE,
      gui = FALSE,
      corpus.lang = "Other",
      plot.custom.height=8,
      plot.custom.width=8,
      save.analyzed.freqs=FALSE
      )

Заглянуть внутрь функции stylo() можно здесь.

16.3 Что такое Delta

Delta Берроуза – это мера стилистической близости между текстами. Метод был предложен в 2001 году австралийским филологом Джоном Бёрроузом. С тех пор дельту используют во многих исследованиях, большая часть которых посвящена установлению авторства различных произведений.

Суть метода заключается в том, что для корпуса текстов рассчитывается частотность ряда признаков; это могут быть слова (словоформы) или так называемые n-граммы, то есть последовательности n символов подряд. Для сравнения берутся самые частотные слова, среди которых будет значительная доля служебных, в наименьшей степени связанных с тематикой текста (предлоги, союзы, частицы и т.п.). Поскольку сравниваемые тексты, как правило, имеют разную длину, в стилометрических исследованиях принято брать для сравнения относительную, а не абсолютную частотность; Берроуз идет еще дальше, предлагая использовать так называемые z-scores, то есть стандартизированные оценки, показывающие разброс значений относительно средних. Z-score вычисляется по формуле:

\[Ζ =\frac{x-\mu}{sd}\]

Здесь случайная величина x — это значение частотности, μ — математическое ожидание (среднее), а sd — стандартное отклонение. Иными словами, z-score показывает, на сколько стандартных отклонений x отстоит от ожидаемого. Зная z-scores для заданных слов у известных авторов/текстов, можно сравнить их с z-scores спорного текста; искомая дистанция Delta вычисляется как сумма взятых по модулю разниц между z-scores у двух сравниваемых текстов, поделенная на количество слов:

\[ΔB = \frac{1}{n}\times\ \sum_{i}^{n}{|z_{i,\ A-}}z_{i,\ B}|\],

где i – конкретное слово, n – общее число слов, а A и B – сравниваемые авторы (знак | указывает, что суммируется абсолютное значение разницы). Чем больше дистанция, тем менее вероятно авторство.

Простота метода позволяет использовать его в традиционных методах обучения без учителя, таких как кластерный анализ, так и с машинно-обучаемыми классификаторами, когда для каждого значения предиктора \(x_i\) имеется значение отклика \(y_i\). Это позволяет, имея показатели предикторов, прогнозировать отклик, то есть, в нашем примере, определять наиболее вероятного автора. Количество классов формально не ограничено: мы можем сравнивать спорные тексты (test set) как с двумя, так и с двадцатью кандидатами, которые включаются в обучающую выборку (training set).

Пакет stylo дает возможность работать не только с классической Delta, но и с ее вариациями, из которых заслуживает внимание т.н. “вюрцбургская Delta”. В отличие от Delta Берроуза, она использует не манхэттенское, а косинусное расстояние, что во многих случаях позволяет повысить точность классификации. Подробнее о разных расстояниях (на примере древнегреческого корпуса) см. наш препринт.

16.4 classify()

Если stylo() возвращает результат, который должен интерпретировать человек, то classify() используется для машинного обучения с учителем. Вызов функции без аргументов вернет GUI, похожий на тот, что мы видели выше. Отличие будет на вкладке “Статистика”.

Среди доступных методов классификации: Delta, k-NN, SVM, Наивный Байес, метод ближайших центроидов. Подробнее о них мы будем говорить позже, а пока можно поэкспериментировать с Delta.

Перед запуском функции необходимо создать в рабочей директории две папки: primary_set и secondary_set (они есть в архиве, который вы уже скачали). В первой находится так называемые обучающие данные, во второй - тестовые (контрольные) данные. Обычно это тексты неизвестного авторства, но к ним можно добавить и несколько произведений известного авторства для дополнительного контроля. Мы примем за спорные отрывок из “Греческой истории” Ксенофонта, диалог “Софист” Платона, “Наблюдателей” Лукиана и “Против софистов” Исократа.

classify_result <- classify(
      training.corpus.dir = "primary_set",
      test.corpus.dir = "secondary_set",
      classification.method = "delta",
      culling.of.all.samples = FALSE,
      analyzed.features="w", 
      culling.max=20,
      culling.min=20,
      ngram.size=1,
      mfw.min = 100,
      mfw.max = 100,
      mfw.incr = 0,
      gui = FALSE,
      corpus.lang = "Other")

После того, как функция вернет управление, в рабочей директории появится несколько файлов, среди них – final_results.txt. В нашем случае успех 100%, но не стоит переоценивать этот результат: пример был совсем игрушечный. О подводных камнях поговорим в модуле про машинное обучение.

classify_result$expected == classify_result$predicted
[1] TRUE TRUE TRUE TRUE

Теперь попробуйте поэкспериментировать с разными методами и настройками.

16.5 samplesize.penalize()

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

Функция samplesize.penalize() позволяет проверить эффективность метода на отрывках разной длины при работе с различными машинно-обучаемыми классификаторами, в том числе Delta.

Функция извлекает из текста случайные выборки все большей и большей длины и сравнивает их с обучающей выборкой для классификации с применением разного числа mfw; по умолчанию для каждой заданной длины отрывка проводится 100 итераций. На выходе функция возвращает матрицы с указанием количества успешных классификаций для каждой длины отрывка и заданного количества mfw, а также матрицы смешения, позволяющие судить о том, между какими авторами чаще возникала путаница.

penalize_result <- samplesize.penalize(mfw = c(100, 200, 500), 
                    features = NULL,
                    path = NULL, corpus.dir = "corpus",
                    sample.size.coverage = seq(100, 5000, 100),
                    sample.with.replacement = TRUE,
                    iterations = 100, 
                    classification.method = "delta")

Функция вернет список с показателями точность и матрицами смешения и некоторыми другими показателями. Подробнее о матрицах смешения мы будем говорить в разделе про машинное обучение, а пока просто посмотрим на то, как это выглядит.

library(tidyverse)

helen_confusion_mfw100 <- penalize_result$confusion.matrices$Isocrates_Hel$mfw_100 |> 
   as.data.frame() |> 
   rownames_to_column("predicted") |> 
   as_tibble()

helen_confusion_mfw100

Вытянем в длину и визуализируем.

helen_confusion_mfw100 |> 
  filter(predicted == "Isocrates") |> 
  pivot_longer(-predicted, 
               names_to = "sample_size", 
               values_to = "n") |> 
  ggplot(aes(as.numeric(sample_size), n)) +
   geom_point(color = "steelblue") + 
   scale_x_continuous(breaks = seq(100, 5000, 300)) +
   labs(x = NULL)

16.6 rolling.delta()

Еще одна “фирменная” функция stylo называется rolling.delta(). Она подходит для тех случаев, когда текст написан в соавторстве (или мы предполагаем, что это так). Delta “прокатится” по всему тексту и для каждого его отрывка оценит вероятность того, что он создан тем или иным автором. Разумеется, это имеет смысл лишь в том случае, если у нас, во-первых, достаточно длинный спорный текст, а, во-вторых, есть понятные кандидаты.

Для демонстрации работы функции мы составили “монстра” из “Бусириса” Исократа и “Софиста” Платона: первая тысяча слов из Исократа, потом две тысячи из Платона, потом еще тысяча из Исократа и тысяча из Платона. Монстр лежит в папке test_set. Обучающие данные находятся в папке reference_set.

rolling.classify(training.corpus.dir = "reference_set",
                 test.corpus.dir = "test_set",
                 write.png.file = FALSE, 
                 classification.method = "delta", 
                 mfw = 150, 
                 corpus.lang="Other", 
                 slice.size = 500, 
                 slice.overlap = 200,
                 plot.legend = FALSE,
                 milestone.points = seq(0, 5000, 500),
                 shading = TRUE
                 )
par(mar=c(0,0,0,0))
legend('top', c("Isocrates","Plato"), 
       col=c('red', 'green'), lty = 2)

16.7 oppose()

Функция oppose() реализует контрастивный анализ, помогая понять, каких слов авторы избегают, а какие – предпочитают. Функция возвращает два файла: words-preferred.txt и words-avoided.txt. Она тоже поддерживает графический интерфейс, но с древнегреческим бывают трудности токенизации, поэтому прописываем правило при помощи регулярных выражений.

Для сравнения возьмем Платона и Исократа (они тоже есть в архиве).

oppose(corpus.format = "plain",
       corpus.lang = "Other",
       primary.corpus.dir = "Plato" , 
       secondary.corpus.dir = "Isocrates", 
       splitting.rule = "[ \t\n]+",
       text.slice.length = 1000,
       text.slice.overlap = 0,
       rare.occurrences.threshold = 3,
       zeta.filter.threshold = 0.05,
       oppose.method = "craig.zeta",
       display.on.screen = TRUE,
       gui = FALSE)

Изменим настройки визуализации и проверим, к кому из двух ближе Демосфен.

oppose(corpus.format = "plain",
       corpus.lang = "Other",
       primary.corpus.dir = "Plato" , 
       secondary.corpus.dir = "Isocrates", 
       test.corpus.dir = "Demosthenes",
       splitting.rule = "[ \t\n]+",
       text.slice.length = 1000,
       text.slice.overlap = 0,
       rare.occurrences.threshold = 3,
       visualization="markers", # изменение тут
       zeta.filter.threshold = 0.05,
       oppose.method = "craig.zeta",
       display.on.screen = TRUE,
       gui = FALSE)

16.8 Что такое Zeta

Контрастивный анализ, который проводит функция oppose(), тоже основан на методе, предложенном Д. Берроузом. Этот метод был описан и доработан рядом других исследователей, в том числе Хью Крейгом.

Его смысл подробно объясняет Savoy (2020), а более популярное объяснение (со ссылками на специальную литературу) можно найти на сайте https://zeta-project.eu. Общий смысл такой. Берутся два корпуса, которые необходимо сравнить. Это может быть корпус мужской и женской прозы, корпус Шекспира и других драматургов его времени, корпус американских и британских детективов… you name it. Один из корпусов (назовем его primary set) принимается за основу сравнения.

Все тексты делятся на фрагменты фиксированной длины, обычно от 900 до 6000 слов (Savoy 2020, 154). Дальше считается, в какой доле фрагментов из primary set слово встретилось и в какой доле фрагментов из secondary set оно не встретилось. Затем доли суммируются (тогда \(0 \leqslant z \leqslant 2\)). Допустим, мы сравниваем Шекспира и Марлоу. Если у Шекспира слово есть во всех фрагментах, а у Марлоу – ни в одном, то \(1 + 1 = 2\). Если наоборот, то \(0 + 0 = 0\). На практике крайние значения встречаются очень редко.

Другой вариант с примерно тем же смыслом. Считаем долю документов, в которых слово встречается у Шекспира и у Марлоу. Например, у Шекспира в 100%, а у Марло - ни в одном. Вторая доля вычитается из первой: \(1 - 0 = 1\). Если наоборот, то Zeta равна \(-1\). Таким образом, \(-1 \leqslant z \leqslant 1\).

Достоинство этого метода в том, что результат легко интерпретировать: мы сразу видим слова-дискриминаторы. Но надо помнить, что Zeta работает не с самыми частотными словами (точнее, не только с ними), а значит подвержена влиянию тематики и жанра.

16.9 imposters()

Об этой функции см. подробнее здесь. На входе она требует уже подготовленные таблицы с частотностями.

data("galbraith")

# забираем 8-й ряд из датасета:
my_text_to_be_tested = galbraith[8,]

# исключаем 8-й ряд из датасета
my_frequency_table = galbraith[-c(8),]

# поехали:
imposters(reference.set = my_frequency_table, 
          test = my_text_to_be_tested,
          iterations = 100,
          features = 0.5)
  coben   lewis rowling tolkien 
   0.37    0.00    1.00    0.00 

Функция возвращает вектор вероятностей, где значения, близкие к 1, соответствуют наиболее правдоподобным кандидатам на авторство.

16.10 Кто такие самозванцы

Функция imposters() реализует метод верификации авторства, предложенный в статье М. Коппеля и Я. Винтера и апробированный на корпусе Цезаря.

Метод подходит для тех случаев, когда кандидатов много, но мы не можем быть уверены в том, что среди них есть нужный. Задача заключается в том, чтобы заставить машину сказать “я не знаю”. Для этого используется следующий алгоритм.

Выбирается часть признаков (например 0.5), на основании которых для спорного текста вычисляются его “ближайшие соседи” в корпусе; для этого может использоваться любая мера расстояния, например Delta, косинусное сходство или др. Потом берется еще одна случайная выборка признаков, снова ищется сосед и так k раз (обычно 100).

Автор считается установленным, если атрибуция одному автору превышает некий установленный порог; значение этого порога устанавливается в зависимости от того, какова цена ошибки, то есть что для нас важнее – точность, precision (доля объектов, названными классификатором положительными и при этом действительно являющимися положительными) или полнота, recall (доля объектов положительного класса из всех объектов положительного класса). В качестве подмоги можно использовать функцию imposters.optimize().

Но подробнее об этом речь пойдет в разделе про машинное обучение.