Стилометрия, или как отличить Акунина от Булгакова с помощью 50 строк кода?

  • Tutorial

Привет, Хабр.

Довольно интересным направлением "прикладной статистики" и NLP (Natural Languages Processing а вовсе не то что многие сейчас подумали) является анализ текста. Появилось это направление задолго до компьютеров, и имело вполне практическую цель: определить автора того или иного текста. С помощью ПК это впрочем, гораздо легче и удобнее, да и результаты получаются весьма интересные. Посмотрим, какие закономерности можно выявить с помощью совсем простого кода на Python.

Для тех кому интересно, продолжение под катом.

История

Одной из первых практических задач было определение авторства политических текстов The Federalist Papers, написанных в США в 1780 годах. Их авторами было несколько человек, но кто есть кто, окончательно было неизвестно. Первый подход к построению кривой распределения длины слов был предпринят еще в 1851 г, и можно представить, какой это был объем работы. Сейчас, слава богу, всё проще. Я рассмотрю простейший способ анализа с помощью несложных расчетов и пакета Natural Language Toolkit, что в совокупности с matplotlib позволяет получить интересные результаты буквально в несколько строк кода. Мы посмотрим, как все это можно визуализировать и какие закономерности можно увидеть.

Те, кому интересны результаты, главу "код" могут пропустить.

Код

Перейдем к практическому примеру. Возьмем для анализа следующий текст:

s = """Ежик сидел  на горке под  сосной и смотрел на освещенную 
       лунным светом долину, затопленную туманом. Красиво было так, что 
       он время от времени вздрагивал: не снится ли ему все это?"""

Подключим библиотеку nltk:

import nltk

nltk.download('punkt')

tokens = nltk.word_tokenize(s)

Массив tokens содержит все слова и знаки пунктуации строки:

['Ежик', 'сидел', 'на', 'горке', 'под', 'сосной', 'и', 'смотрел', 'на', 
 'освещенную', 'лунным', 'светом', 'долину', ',', 'затопленную', ...]

Отфильтруем массив, удалив из него знаки препинания и переведем слова в нижний регистр:

import string

remove_punctuation = str.maketrans('', '', string.punctuation)
tokens_ = [x for x in [t.translate(remove_punctuation).lower() for t in tokens] if len(x) > 0]

Теперь мы можем получить первый статистический параметр: лексическое разнообразие текста. Это соотношение числа уникальных слов к их общему количеству.

text = nltk.Text(tokens_)
lexical_diversity = (len(set(text)) / len(text)) * 100

Для данного текста этот параметр равен 96.6%.

Несложно получить среднюю длину слова:

words = set(tokens_)
word_chars = [len(word) for word in words]
mean_word_len = sum(word_chars) / float(len(word_chars))

Множество set(tokens_) дает нам неповторяющийся список слов, далее мы просто вычисляем среднее, разделив сумму на количество. Для этого текста средняя длина слова равна 4.86.

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

import numpy as np
sentences = nltk.sent_tokenize(s)
sentence_word_length = [len(sent.split()) for sent in sentences]
mean_sentence_len = np.mean(sentence_word_length)

Для нашего текста длина предложения составляет 15 слов.

И последний параметр - частотность появления различных симолов. У каждого автора может быть свой стиль использования запятых, вопросов и кавычек, разных несклоняемых частей речи ("что", "в"). Для примера посчитаем частоту использования запятых на 1000 символов текста:

fdist = nltk.probability.FreqDist(nltk.Text(tokens))
commas_per_thousand = (fdist[","] * 1000) / fdist.N()

Для данного текста параметр составляет 57.14 запятых на 1000 символов.

Последнее, что нам нужно сделать - загружать текст из файла.

import codecs

try:
    doc = codecs.open(file_name, 'r', 'cp1251').read()
except:
    doc = codecs.open(file_name, 'r', 'utf-8').read()

Как можно видеть, здесь есть два варианта. Часть файлов, скачанных из онлайн-библиотек, хранятся в кодировке 1251. Другая часть файлов сохранена методом copy-paste в Блокноте, и имеет более современную кодировку UTF-8. Вышеприведенный метод сначала пытается открыть файл как 1251, в случае неудачи мы считаем что это UTF-8, на практике такого подхода оказалось вполне достаточно.

Визуализация

Пока все выглядит довольно скучно. Гораздо интереснее становится тогда, когда эти данные можно увидеть графически. Я взял наугад по одной книге от 4х известных авторов, тексты были взяты со всем известной Библиотеки Максима Мошкова Lib.ru. Каждая книга разбивается на блоки одинаковой длины, для каждого блока параметры вычисляются вышеописанным способом.

Лексическое разнообразие и средняя длина слова не дают какой-либо заметной разницы:

Очевидно, все авторы люди образованные, тексты написаны хорошим и литературным русским языком, какой-либо значимой разницы в объеме используемых слов не видно. Параметр "длина слова" тоже не дает видимых отличий. А вот средняя длина предложения отличается весьма заметно:

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

Он боялся, Лужин старший, что, когда сын узнает, зачем так нужны были совершенно безликие Трувор и Синеус, и таблица слов, требующих ять, и главнейшие русские реки, с ним случится то же, что два года назад, когда, медленно и тяжко, при звуке скрипевших ступеней, стрелявших половиц, передвигаемых сундуков, наполнив собою весь дом, появилась француженка.

Количество запятых на 1000 слов также отличается, и это очевидно - в длинном предложении их, очевидно, и должно быть больше:

Разумеется, анализ можно делать по любому символу, например, можно сравнить, как часто у разных авторов встречается знак двоеточия ":":

Несложно вывести частоты появления различных символов в виде кривой, взяв по одной книге от каждого автора:

Частоты употребления разных символов в русском языке похожи, но различия в стиле разных авторов все же есть. Для сравнения, вот так выглядит кривая для разных книг одного автора:

Идея, надеюсь, понятна. "Отпечаток" использования различных символов отличается для разных авторов, и как было показано, технологию можно использовать даже для выявления клонов на популярном англоязычном сайте reddit.com. Впрочем, насколько достоверно это работает для русского языка, автору неизвестно.

Мы же рассмотрим пример попроще. Популярная в СССР детская книга "Улица младшего сына" имеет двух авторов, Лев Кассиль и Макс Поляновский. На графике хорошо видно статистическое различие по Lexical Diversity. Можно предположить что начало книги писал один автор, а закончил другой:

И последний, наиболее любопытный пример. Ниже приведен график лексического разнообразия для 10 книг одной популярной дамской писательницы, чьи книги одно время продавались буквально на каждой остановке. Ещё тогда я удивлялся, можно ли писать столько книг в таком количестве. Результат интересен, на графике определенно видна статистическая аномалия - стиль как минимум, одной книги заметно отличается от остальных:

Но разумеется, может это и просто совпадение, теория вероятности такое, в принципе, допускает. Более того, только один параметр не является доказательством, для примера можно посмотреть, какое количество характеристик текста может использоваться для анализа авторства.

Заключение

Вышеприведенный анализ показался довольно интересным. Используя несложные, практически школьные, формулы, можно получить довольно любопытные результаты. Разумеется, анализ можно и усложнить, например, можно попробовать определить, менялся ли стиль автора с годами, вариантов тут много.

Для желающих поэкспериментировать самостоятельно, исходный код для Python 3.7 приведен под спойлером.

text_process.py

import nltk, codecs
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import Optional, List
import string
import glob
import sys, os


def get_articles_from_blob(folder: str):
    data = []
    for path in glob.glob(folder + os.sep + "*"):
        print(path)
        data += get_articles_from_folder(path)
    return data

def get_articles_from_folder(folder: str):
    data = []
    for path in glob.glob(folder + os.sep + "*.txt"):
        data += get_data_from_file(path)
    return [(folder.split(os.sep)[-1], data)]

def get_data_from_file(file_name: str):
    print("Get data for %s" % file_name)
    try:
        doc = codecs.open(file_name, 'r', 'cp1251').read()
    except:
        doc = codecs.open(file_name, 'r', 'utf-8').read()
    chunk_size = 25000
    data = []
    for part in [doc[i:i+chunk_size] for i in range(0, len(doc) - (len(doc) % chunk_size), chunk_size)]:
        data.append(get_data_from_str(part[part.find(' '):part.rfind(' ')]))
    return data

def get_data_from_str(doc: str):
    tokens = nltk.word_tokenize(doc)
    remove_punctuation = str.maketrans('', '', string.punctuation)
    tokens_ = [x for x in [t.translate(remove_punctuation).lower() for t in tokens] if len(x) > 0]
    text = nltk.Text(tokens_)
    lexical_diversity = (len(set(text)) / len(text)) * 100

    words = set(tokens_)
    word_chars = [len(word) for word in words]
    mean_word_len = sum(word_chars) / float(len(word_chars))

    sentences = nltk.sent_tokenize(doc)
    sentence_word_length = [len(sent.split()) for sent in sentences]
    mean_sentence_len = np.mean(sentence_word_length)

    fdist = nltk.probability.FreqDist(nltk.Text(tokens))
    commas_per_thousand = (fdist[","] * 1000) / fdist.N()
    return (lexical_diversity, mean_word_len, mean_sentence_len, commas_per_thousand)

def plot_data(data):
    plt.rcParams["figure.figsize"] = (12, 5)
    fig, ax = plt.subplots()

    plt.title('Lexical diversity')
    for author, author_data in data:
        plt.plot(list(map(lambda val: val[0], author_data)), label=author)
    plt.ylim([40, 70])

    # plt.title('Mean Word Length')
    # for author, author_data in data:
    #     plt.plot(list(map(lambda val: val[1], author_data)), label=author)
    # plt.ylim([4, 8])

    # plt.title('Mean Sentence Length')
    # for author, author_data in data:
    #     plt.plot(list(map(lambda val: val[2], author_data)), label=author)
    # plt.ylim([0, 30])

    # plt.title("Commas per thousand")
    # for author, author_data in data:
    #     plt.plot(list(map(lambda val: val[3], author_data)), label=author)

    plt.legend(loc='upper right')
    plt.tight_layout()
    plt.show()
    
def get_freqs_from_folder(folder: str):
    freqs_data = []
    for path in glob.glob(folder + os.sep + "*.txt"):
        print("Get data for %s" % path)
        try:
            doc = codecs.open(path, 'r', 'cp1251').read()
        except:
            doc = codecs.open(path, 'r', 'utf-8').read()
        symbols, freqs = get_freqs_from_str(doc)
        freqs_data.append((path.split(os.sep)[-1], symbols, freqs))
    return freqs_data

def get_freqs_from_str(doc: str):
    tokens = nltk.word_tokenize(doc)
    tokens = [x for x in [t.lower() for t in tokens]]
    fdist = nltk.probability.FreqDist(nltk.Text(tokens))

    symbols = [",", ":", "(", ")", "!", "в", "что", "где", "как", "вот", "так", "и", "или", "не", "даже", "да", "нет", "но"]
    freqs = []
    for s in symbols:
        freq = (fdist[s] * 1000) / fdist.N()
        if s == ",":
            freq /= 2
        freqs.append(freq)
    return (symbols, freqs)

def plot_freqs(data):
    plt.rcParams["figure.figsize"] = (12, 5)

    for author, symbols, freqs in data:
        plt.plot(symbols, freqs, label=author)

    plt.legend(loc='upper right')
    plt.tight_layout()
    plt.show()


if __name__ == "__main__":
    # Download punkt tokenizer
    try:
        nltk.data.find('tokenizers/punkt')
    except LookupError:
        nltk.download('punkt')

    # Process text files
    # data = get_articles_from_blob("Folder")  # Folder/AuthorXX/Text.txt
    data = get_articles_from_folder("folder_here")  # Folder with files
    plot_data(data)
    
    # Process frequency curve
    data = get_freqs_from_folder("folder_here")
    plot_freqs(data)

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 24

    +4
    Немного оффтоп: журнал «Знание (серия „Знак вопроса“) Другому как понять тебя?»
    Простым языком рассматривается лингвистический анализ текстов для чайников.
    p.s. Ну ооочень древний, с детства запомнился.
      +1
      Даже интересно стало, вы про Маринину или про Донцову в конце рассказали?
        +1
        Вы же понимаете, я не хочу получить иск о защите деловой репутации, поэтому никаких фамилий в тексте нет :) Готовый код выложен, можете поэкспериментировать самостоятельно. В принципе, для подобных авторов, издающих книги на потоке «раз в месяц» (я до сих пор не понимаю как это возможно:) может быть интересно проанализировать тексты по сериям, годам, количеству книг и пр.
        +2
        Стиль изложения каждого автора очень индивидуальная черта, мне кажется тут нужен более глубокий анализ, нежели простой подсчет длины предложений и количества запятых.
          0
          Одно другому не мешает. Если цель — определение (или опровержение) авторства, то базовыми методами можно отсечь наименее правдоподобные варианты, затем уже переходить к более глубокому анализу.

          Так-то да, идей, что можно проанализировать много, словосочетания, часто используемые слова, пары слов и пр.
          +1

          Тут еще нужна LDA (пост на хабре). Если кому интересны инструменты по теме, то вот один из вариантов с обилием ссылок

            0
            Интересная заготовка! А можно свести все эти и другие синтетические параметры, полученные по различным книгам одного автора, и построить нормальный такой классификатор. И в теории он сможет определять авторство текста с какой-то вероятностью. Другой вопрос, что фичи придется поискать более сложные. Частотность маркерных слов и словосочетаний, средняя частотность глаголов/существительных/прилагательных/местоимений/… и т.д.
            А еще забавнее было бы прогнать, например, по массиву диссертаций и поискать кластера предположительно одного авторства.
              +1
              Есть практически готовая библиотека на эту тему: github.com/jpotts18/stylometry

              Метод predict там есть, насколько хорошо работает, не изучал.
                0
                Добавлю, библиотека старая, и перед использованием её придется портировать на Python 3.7.
                  0
                  Скорее всего, русскую морфологию не подхватит. Ну и как они сами пишут, одноразовый фан проект, который не поддерживается 6 лет. Но все равно спасибо! Нет идей что-то сделать дополнительно с вашим сабжевым проектом? Ведь наверняка из него можно выжать больше?
                    +1
                    Они используют тот же NLTK, так что в принципе заработает, но разумеется, никаких склонений/падежей (или чего-то более сложного) для русского там нет.
                      0
                      Нет идей что-то сделать дополнительно с вашим сабжевым проектом? Ведь наверняка из него можно выжать больше?

                      Самое простое что можно сделать, это подать данные с частотного парсинга на вход нейросети в keras и посмотреть, будет ли оно как-то определяться. Но внутренний голос подсказывает, что точность на коротких фрагментах будет весьма низкая.
                  0
                  Интересно, существуют ли нейросети для стилизации текста? Не для генерации, а именно для копирования стилистики? Чтобы сначала скормить сетке пару книг желаемого автора, а потом — текст собственного фанфика, и сеть стилизовала этот фанфик так, чтобы читатель не отличил его по слогу от оригинала?
                  Для рисованных картин такое есть, и работает вполне неплохо. А для текстов?
                    –1
                    >> Ответ лично для меня стал очевиден, на графике определенно видна статистическая аномалия — стиль как минимум, одной книги заметно отличается от остальных:

                    Странный вывод. Даже если предположить, что на графике мы видим не разброс, а тенденцию, и далее следовать вашему предположению, что автор должен всегда писать в одном стиле, и не может его менять намеренно, с какими-то своими литературными целями, — то все равно, формально следует, что все остальные книги она написала сама, только пара книг не ее? И тогда это мало что меняет?
                    Но по-настоящему поражает само ваше предположение. А вы как-то пытались, действуя в научном стиле, проверить его? Ну скажем, сравнить рассказы Льва Толстого для детей, и «Войну и Мир»? Дадут ли они одинаковый отпечаток автора по вашему методу? Или разные тексты Сорокина? Или первый роман про Гарри Поттера, и первый роман про Корморона Страйка?
                      –2
                      Жанры совпадают естественно, детские книги со взрослыми никто не сравнивал. Хотя в тексте и было указано, что «возможно это совпадение». Все исходные коды выложены, пробуйте если есть интерес.

                      А если вы думаете, что случаев когда кто-то писал за другого, в литературном мире не существует, почитайте:
                      www.m24.ru/articles/literatura/26022014/38594
                      versia.ru/skolko-zarabatyvayut-literaturnye-negry

                      Я разумеется никого не обвиняю, все совпадения могут быть случайными :)))

                      А вы как-то пытались, действуя в научном стиле, проверить его? Ну скажем, сравнить рассказы Льва Толстого для детей, и «Войну и Мир»? Дадут ли они одинаковый отпечаток автора по вашему методу? Или разные тексты Сорокина? Или первый роман про Гарри Поттера, и первый роман про Корморона Страйка?

                      Вы случайно не путаете науч-поп статью с диссертацией? Цели что-то доказать здесь не было.
                        0
                        Вы зря так негативно воспринимаете. Я просто хотел вас предупредить, что среди стилометристов встречаются насильники.
                        И если речь шла все-таки про Донцову, то вы просто ошибаетесь. И в конкретно ее случае, и вообще — может ли человек выдавать на-гора столько текста? Может. Возможно, вы слышали про Азимова? О числе его научно-популярных работ? А он начинал еще во времена, когда основным инструментом писателя была пишмашинка, и редактирование текста было куда труднее, чем сейчас на компьютере.
                          0
                          Спасибо за мнение. Никакого негатива и не было, фраза «лично для меня» в тексте по-моему вполне определенно указывает что это лишь личное мнение, а не доказательство теоремы :) Может я и ошибаюсь, а может и нет, гипотетически не исключаю что у некоторых авторов больше бизнес чем творчество. Полный анализ того или иного автора (особенно если там 100-500 книг) это не формат Хабра разумеется, ни по стилю, ни по трудозатратам.
                      +1
                      Есть одна довольно известная (и на мой дилетантский взгляд, довольно правдоподобная) конспирологическая теория о том, что автором «12 стульев» и «Золотого телёнка» является на самом деле Булгаков. Не хотите проверить?
                        +1
                        Взял для теста 4 книги Булгакова и 2 книги Ильфа и Петрова. Длина предложения и лексическое разнообразие более-менее схожи, но по количеству запятых различие довольно заметно.
                        График


                        Имхо не очень похоже, чтобы писал один человек, на графике разделение на 2 группы четко видно.
                          0
                          Ну давно же есть подобные онлйн-игралки, причем куда более продвинутые, чем предлагаемый хеллоуворлд: fantlab.ru/rating/work/lingvo
                          0

                          Очень сложно, но в то же время очень интересно!

                            +1
                            Спасибо. Давно есть идея спарсить онлайн издания, и отследить как менялся стиль в зависимости от редакторов или модных слов.
                            А как можно найти автора по определенным оборотам речи или словосочетаниям?
                              0
                              >как найти автора по
                              Я бы брал пласт тематических текстов. Т.е. если ищем футпринт автора художественной литературы, то берем художку, причем желательно максимально близкого жанра. Считаем частотность слов и выражений по всему пласту. Затем считаем по отдельным авторам авторскую частотность. Находим разность между авторской частотностью и средней. Пики — авторские словечки. Смотрим, какие слова характерны для конкретного текста и ищем наиболее близкого автора. Хотя, конечно, внутри море подводных камней. Придется искать что-то в духе «автор чаще использует „этот“ чем „который“, „и“ чем „а“ и т.п. Важно не хватануть слов, которые относятся к сюжету, а не самому автору. Т.е. не начать считать, что маркерное для Булгакова слово — »яйцо", тк оно часто встречается в роковых яйцах.
                              Я похожим образом в свое время делал модуль автопоиска ключевиков для статьи на сайте. В том приложении работало хорошо.
                              +1
                              Я люблю перечитывать запомнившиеся места в книгах, но в большом произведении с невнятно названными главами их бывает сложно находить.

                              Сделал простейший анализатов — берем самые популярные слова в главе, убираем из них самые популярные в тексте.

                              Результат неплохо подсказывает содержание главы github.com/vashu1/data_snippets/tree/master/bag_of_words_chapters

                              Это конечно несколько оффтоп, но имхо хорошая демонстрация силы простых методов даже в такой сложной теме как тексты.

                              Only users with full accounts can post comments. Log in, please.