Анализ авторства текста полносвязной нейронной сетью в Keras

Рассмотрим как в простейшем случае решается задача определения автора текста.

  • Дано: тексты 6+1 авторов в *.txt, кодировка UTF8. Разделены на обучающую и проверочную выборку, т.е. на каждого автора по 2 текста. +1 — два текста Марининой идут в отдельном архиве.
  • Нужно: решить задачу классификации, т.е. по тексту определить автора.

Анализ текста буду проводить с помощью простейшей сети в Colab с помощью библиотеки Keras. Решение задачи доcтаточно подробно разобрано основателем «Университета искусственного интеллекта» Дмитрием Романовым. Однако, мне для лучшего усвоения материала предпочтительнее текстовый вариант с максимально детальным разбором ключевых моментов. Поэтому написал вот этот расширенный конспект лекции, чтобы глубже разобраться в теме. Colab notepad с моими экспериментами здесь.

Импорт библиотек

from google.colab import files
import numpy as np
import pandas as pd
import os
import matplotlib.pyplot as plt
%matplotlib inline
from tensorflow.keras import utils

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Dropout, SpatialDropout1D, BatchNormalization, Embedding, Flatten, Activation, SimpleRNN
from tensorflow.python.keras.optimizers import Adam, RMSprop

from keras.preprocessing.text import Tokenizer, text_to_word_sequence
from sklearn.model_selection import train_test_split

Загрузка файлов

Тексты авторов достаточно объемные и для удобства работы с Colab размещены в сжатом zip файле, чтобы удобно и быстро скачивать при дисконнектах notebook. Естественно, можно использовать загрузку файлов вручную, но это все отнимает время. Кроме того при перезапусках notebook, если правильно написать подгрузку данных, чтобы код не задавал лишних вопросов, можно запускать на выполнение с помощью команды «Run before». Это значительно уменьшает трудозатраты при повторных перезапусках.

Для удобства я использую следующий подход. Функция, которая подгружает файл в Colab с ftp/http с помощью WGET:

#Загружаем архив с текстами с FTP/HTTP
def loadfiles(filename, fullpath):
  loaded = True
  if os.path.isfile(filename):
      print("File \"", filename, "\" has already downloaded.")
  else:
    print("Start downloading", filename)
    !wget $fullpath
    !unzip $filename 
    loaded = False

  return loaded    
   
!ls

Для вызова функции используем следующий код:

URL = "http://www.bizkit.ru/wp-content/uploads/COLAB/"
filename = "Writers_texts.zip"
fullpath = URL + filename
print(fullpath)

loaded = loadfiles(filename, fullpath) 

Иногда нужно удалить старые файлы при загрузке обновленного контента. По-умолчанию код закомментарен.

if loaded:
  #!rm $filename

Поскольку тексты авторов находятся в zip архиве — нужно их распаковать, поэтому в функции вызывается код:

!unzip $filename

Естественно, можно распаковывать тексты в определенную папку на Colab, но при дисконнекте notebook данные затираются, поэтому в этом нет особого смысла.

Загрузка данных

При загрузке данных может потребоваться их вычистка для удаления незначимых слов или символов. Можно использовать штатный replace() или сделать множественную замену, передав в качестве аргумента список заменяемых слов. Функция очень простая:

def replaceMultiple(mainString, toBeReplaces, newString):
    # Iterate over the strings to be replaced
    for elem in toBeReplaces:
        # Check if string is in the main string
        if elem in mainString:
            # Replace the string
            mainString = mainString.replace(elem, newString)
    
    return  mainString

Пример использования:

txt = replaceMultiple("Тестовая - запись — верна", ['\n\r', '\n', '\r', '-', '—'] , " ")
print(txt)

Тестовая   запись   верна

Исходный текст загружаем в строковую переменную для дальнейшей обработки:

def readText(fileName): #функция принимает имя файла
  f = open(fileName, 'r')        #открываем файла в режиме чтения
  text = f.read()                #читаем текст
  #text = replaceMultiple(text, ['\n\r', '\n', '\r', '\\xa0'] , " ") #заменяем переносы и спецсимволы разделителей на пробелы
  return text                    #функция возвращает текст файла

И подгружаем в список (list) тексты всех анализируемых авторов:

#Загружаем обучающие тексты
trainText = []
trainText.append(readText('(О. Генри) Обучающая_50 вместе.txt'))
trainText.append(readText('(Стругацкие) Обучающая_5 вместе.txt'))
trainText.append(readText('(Булгаков) Обучающая_5 вместе.txt'))
trainText.append(readText('(Клиффорд_Саймак) Обучающая_5 вместе.txt'))
trainText.append(readText('(Макс Фрай) Обучающая_5 вместе.txt'))
trainText.append(readText('(Рэй Брэдберри) Обучающая_22 вместе.txt')) 
trainText.append(readText('Маринина - Бой тигров в долине. Том 1 .txt')) 

className = ["О. Генри", "Стругацкие", "Булгаков", "Саймак", "Фрай", "Брэдбери", "Маринина"]
nClasses = len(className)

#Загружаем тестовые тексты
testText = []
testText.append(readText('(О. Генри) Тестовая_20 вместе.txt'))
testText.append(readText('(Стругацкие) Тестовая_2 вместе.txt'))
testText.append(readText('(Булгаков) Тестовая_2 вместе.txt'))
testText.append(readText('(Клиффорд_Саймак) Тестовая_2 вместе.txt'))
testText.append(readText('(Макс Фрай) Тестовая_2 вместе.txt'))
testText.append(readText('(Рэй Брэдберри) Тестовая_8 вместе.txt'))
testText.append(readText('Маринина - Бой тигров в долине. Том 2 .txt'))

#Смотрим размеры загруженных выборок
for i in range(len(trainText)):
  print("Длина обучающего текста", className[i], "\t", len(trainText[i]), "\tПроверочного:", "\t", len(testText[i]))

Для загрузки текстов можно использовать более компактный код используя ‘(‘ в качестве токена для определения нужных файлов:

writers_text=[]
for files in os.listdir():
  if (files.startswith('(')):
    with open(files, 'r') as f:
      text = f.read()
      print("Файл:", files, 'длина:', len(text))
      writers_text.append(text)

В двух списках содержатся тексты каждого из 6+1 авторов для обучающей и проверочной выборки:

Длина обучающего текста О. Генри 	 1049517 	Проверочного: 	 349662
Длина обучающего текста Стругацкие 	 2042469 	Проверочного: 	 704846
Длина обучающего текста Булгаков 	 1765648 	Проверочного: 	 875042
Длина обучающего текста Саймак 	 1609507 	Проверочного: 	 318811
Длина обучающего текста Фрай 	 3700010 	Проверочного: 	 1278191
Длина обучающего текста Брэдбери 	 1386454 	Проверочного: 	 868673
Длина обучающего текста Маринина 	 504955 	Проверочного: 	 474573

В случае c Марининой я взял первый том книги «Бой тигров в долине» в качестве обучающей выборки, а второй — в качестве проверочной. Кстати, включение книг Марининой в список слегка ухудшило качество распознавания с 99% до 97% Впрочем, однозначно утверждать нельзя, поскольку исходные веса нейронной сети выбираются случайным образом и локальный оптимум мог быть найден не лучший.

Предобработка данных

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

Для такой предобработки в Keras есть класс Tokenizer. Его методы довольно подробно рассмотрены здесь. Немного поясню, что он делает.

При инициализации класса ему в качестве аргумента передается набор параметров:

  • num_words — количество слов, которое вернет Tokenizer. Рассчитывается статистика повторяемости всех слов после фильтрации. Затем сортируются в порядке убываемости и берутся первые num_words слов.
  • filters — какие символы исключить из текста. Стандартный набор символов: ‘!–»—#$%&()*+,-./:;<=>?@[\]^_`{|}~\t\n\r’
  • lower = True — приводит к нижнему регистру, чтобы исключить разницу между одинаковыми словами в разных регистрах.
  • split=’ ‘ — разделение слов по символу пробела.
  • char_level=False — если False, то текст делится на слова, а если True — на символы.
  • oov_token — если указан токен, то на него будут заменятся не попавшие в словарь слова при вызове метода text_to_sequence.
maxWordsCount = 20000 #макс. кол-во слов/индексов для обучения текстов

tokenizer = Tokenizer(num_words=maxWordsCount, filters='!–"—#$%&amp;amp;()*+,-./:;&amp;lt;=>?@[\\]^_`{|}~\t\n\r«»', lower=True, split=' ', char_level=False)

tokenizer.fit_on_texts(trainText) #передаем тексты для получения токенов отсортированных по частоте повторяемости в количестве maxWordsCount
items = list(tokenizer.word_index.items()) #берем индексы слов для просмотра

print(items[:20])

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

[('и', 1), ('в', 2), ('не', 3), ('я', 4), ('что', 5), ('на', 6), ('с', 7), ('он', 8), ('а', 9), ('как', 10), ('то', 11), ('это', 12), ('но', 13), ('все', 14), ('у', 15), ('по', 16), ('его', 17), ('к', 18), ('так', 19), ('мне', 20)]

Стоит или не стоит оставлять союзы при распознавании текста авторов — вопрос. Это гипотеза для проверки. Чтобы убрать — достаточно добавить соответствующие односимвольные разделители в filters.

Какие ещё полезные свойства есть у Tokenizer?

print(tokenizer.word_index['лиса']) #позиция/индекс слова в массиве токенов
print(tokenizer.word_docs['лиса']) #в скольки источниках встретилось слово
print(tokenizer.word_counts['лиса']) #количество повторений слов

4918
6
35

Соотвественно, в отсортированном списке слов-токенов ‘лиса’ находится на 4918 позиции. Встретилась в 6 книгах из 7 и использовалось 35 раз.

Если вывести список слов со статистикой повторений, то видно, что слова располагаются в той-же последовательности, как в исходном тексте.

dist = list(tokenizer.word_counts.items())
print(dist[:20])
print(trainText[0][:100])

[('лиса', 35), ('на', 27709), ('рассвете', 65), ('коралио', 91), ('нежился', 6), ('в', 45508), ('полуденном', 2), ('зное', 3), ('как', 13748), ('томная', 2)]
«Лиса-на-рассвете»   Коралио нежился в полуденном зное, как томная красавица в сурово хранимом гарем

Имея список с количеством повторений можно убрать слова, которые встречаются в текстах меньше определенного количества раз. Это может быть полезно для очистки текстов от редко повторяющися уникальных слов, которые встречаются только у одного автора. Такие слова могут вводить в заблуждение сеть, поскольку она будет с высокой вероятностью сопоставлять такое слово с автором. Как только появится автор, испольующий такие-же редкие слова, может потребоватся переобучение сети, чтобы она не путала авторов.

count_thres = 2 #кол-во раз меньше которого слово нужно исключить из списка  
low_count_words = [w for w,c in tokenizer.word_counts.items() if c < count_thres] #создаем список слов встречаюихся менее count_thres 
print(len(low_count_words))
print(low_count_words[:20])

for w in low_count_words: #удаляем такие слова из исходного списка
    del tokenizer.word_index[w]
    del tokenizer.word_docs[w]
    del tokenizer.word_counts[w]

63955
['хранимом', 'брильянтиком', 'вкрапленным', 'нависая', 'тюремщик', 'сейбах', 'склоняли', 'кордебалет', 'прима', 'busca']

Заметим, что найдено 63955 слов встречающиеся в текстах менее 1 раза (только у одного автора). Необходимо проверять гипотезу, возрастет ли качество распознавания если исключить такие «шумовые» слова.

После предобработки нужно преобразовать текст в последовательность индексов в соответствии с частотным словарем.

trainWordIndexes = tokenizer.texts_to_sequences(trainText) #обучающие тесты в индексы
testWordIndexes = tokenizer.texts_to_sequences(testText)  #проверочные тесты в индексы

print("Исходный текст:\t\t", trainText[1] [:87])
print("Он же в виде последовательности индексов:\t", trainWordIndexes[1][:20])

Исходный текст:					 Парень из преисподней     1     Ну и деревня! Сроду я таких деревень не видел и не знал
Он же в виде последовательности индексов:	 [464, 21, 1537, 46, 1, 12384, 7750, 4, 399, 3, 254, 1, 3, 247, 54, 5, 225, 9305, 2577, 180]

Статистика по обучающим текстам:

symbs = 0;
words = 0;
for i in range(len(trainText)):
  print(className[i], "\t", len(trainText[i]), "символов,", len(trainWordIndexes[i]), " слов")
  symbs += len(trainText[i])
  words += len(trainWordIndexes[i])
 
print("\r\nВ сумме:\t", symbs, "символов,", words, "слов")

print()
print("Статистика по обучающим текстам:")
symbs = 0;
words = 0;
for i in range(len(testText)):
  print(className[i], "\t", len(testText[i]), " символов, ", len(testWordIndexes[i]), " слов")
  symbs += len(testText[i])
  words += len(testWordIndexes[i])
  
print("\r\nВ сумме:\t", symbs, "символов,", words, "слов")

О. Генри 	 1049517 символов, 131923  слов
Стругацкие 	 2042469 символов, 264513  слов
Булгаков 	 1765648 символов, 214398  слов
Саймак 	 1609507 символов, 221265  слов
Фрай 	 3700010 символов, 501686  слов
Брэдбери 	 1386454 символов, 183143  слов
Маринина 	 504955 символов, 68633  слов

В сумме:	 12058560 символов, 1585561 слов

Статистика по обучающим текстам:
О. Генри 	 349662  символов,  41947  слов
Стругацкие 	 704846  символов,  87409  слов
Булгаков 	 875042  символов,  106240  слов
Саймак 	 318811  символов,  42715  слов
Фрай 	 1278191  символов,  164646  слов
Брэдбери 	 868673  символов,  106731  слов
Маринина 	 474573  символов,  61716  слов

В сумме:	 4869798 символов, 611404 слов

Создание обучающей и проверочной выборки

На предыдущем шаге в trainWordIndexes получили последовательность индексов представляющих слово в исходном тексте. Чем чаще слово появляется в тексте, тем меньше индекс.

Если использовать one hot encoding (OHE) над каждым списком, например, 0-й книги, то получим len(trainWordIndexes[0]) векторов длиной по maxWordsCount = 20000 (общее количество слов в словаре). Каждый вектор содержит лишь одну 1-цу на позиции соотвествующей индексу. Разреженная матрица (sparse matrix) в которой преимущественно нули.

Дальше формируется Bag of Words (BOW). Фактически из разреженной матрицы создается вектор длиной maxWordsCount = 20000. В нем 1-цы стоят на позициях, где слово есть и 0, где нет. Грубо говоря для текста в 20000 слов, все слова которого использовались для создания словаря на всех позициях будут стоять 1-цы, нолей не будет.

Например, после сортировки слов в тексте было получено, что слово кошка встречается довольно часто и индекс равен 8.

Кошка [0 0 0 0 0 0 0 0 1 0 0 0 0 ... 0 0 0] 
OR
Ушла  [0 0 0 0 0 0 0 0 0 0 0 0 0 ... 0 1 0] 
OR
Спать [0 0 0 0 0 0 0 1 0 0 0 0 0 ... 0 0 0]
______________________________________________
BOW:  [0 0 0 0 0 0 0 1 1 0 0 0 0 ... 0 1 0]

На вход сети нужно подать one hot encoding (OHE). Можно обрезать исходный текст до необходимой длины (pad sequence) и сделать OHE. Если сделать OHE над входной последовательностью индексов после обрезания, то нули будут в тех позициях в векторе, где слово было отброшено.

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

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

Для добавления информации о взаимных расположениях слова в тексте используют n-граммы (n-grams). Если используется комбинация двух слов, то получаем биграмму (2-грамму), трех — триграмму. В данном примере такое усложнение не требуется. Даже при таком упрощении нейронная сеть обеспечивает хорошее качество распознавания текстов авторов.

Для увеличения размера обучающей последовательности для текстов используют следующий трюк. Берут «виртуальное окно» длиной xLen и продергивают его вдоль последовательности индексов слов входного текста со смещением shift. Всё, что попало в «окно» добавляют в элемент массива в качестве нового блока текста. Таким образом размер обучающей выборки многократно возрастает, при этом, в данном случае, с текстом не происходит критичных статистических изменений, которые могли бы сказаться на качестве обучения.

###########################
# Формирование обучающей выборки по листу индексов слов
# (разделение на короткие векторы)
##########################
def getSetFromIndexes(wordIndexes, xLen, shift):
  xSample = []
  wordsLen = len(wordIndexes)
  index = 0
  
  #Идём по всей длине вектора индексов
  #"Выбираем" блоки текст длины xLen и смещаемся вперёд на shift
  while (index + xLen <= wordsLen):
    xSample.append(wordIndexes[index:index+xLen])
    index += shift
    
  return xSample

#Тестируем работу функции
arr = [x for x in range(23)]
print("Длина:", len(arr), "\r\n")
print("Входной текст:", arr, "\r\n")
indexes = getSetFromIndexes(arr, 10, 3)

for i in range(len(indexes)):
  print(indexes[i])

print("Общий размер:", len(indexes), "x", len(indexes[0]), '=', len(indexes[0]) * len(indexes))  

Длина: 23 

Входной текст: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22] 

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
[6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
[9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
[12, 13, 14, 15, 16, 17, 18, 19, 20, 21]
Общий размер: 5 x 10 = 50

Для преобразования одномерного массива индексов в последовательность 0 и 1 для подачи на вход нейронной сети используется функция Keras: to_categorical():

arr = to_categorical(trainWordIndexes[0])
print(len(trainWordIndexes[0]))
print(arr.shape)
print(trainWordIndexes[0][:10])
print(arr[:20])

131923
(131923, 20000)
[4918, 6, 2599, 1820, 2, 10, 6230, 2, 3328, 274]
[[0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]
 ...
 [0. 0. 0. ... 0. 0. 0.]
 [0. 0. 1. ... 0. 0. 0.]
 [0. 0. 0. ... 0. 0. 0.]]

Объединим все тексты книг в одну обучающую матрицу и создадим для неё проверочную матрицу.

  • В цикле проходимся по индексам слов текста каждой книги (для каждой книги одна строка матрицы). Длина каждой строки матрицы равна количеству слов в тексте книги.
  • Конвертируем последовательность индексов слов с помощью «виртуального окна» (функция getSetFromIndexes) в расширенную обучающую матрицу.
  • Для каждой строки обучающей матрицы создаем матрицу правильных ответов ySamples.
def createSetsMultiClasses(wordIndexes, xLen, step):
  #Для каждого из классов создаём обучающую/проверочную выборку из индексов
  nClasses = len(wordIndexes) #задаем количество классов выборки
  xSamples = [] #здесь будет список размером "суммарное кол-во окон во всех текстах*длину окна(например 15779*1000)"
  ySamples = [] #здесь будет список размером "суммарное кол-во окон во всех текстах*вектор длиной по количеству классов" 
  for t, wI in enumerate(wordIndexes):  
    tmp = getSetFromIndexes(wI, xLen, step) #получаем список индексов, разбитый на "кол-во окон * длину окна" 
    xSamples += tmp
    ySamples += [utils.to_categorical(t, nClasses).tolist()] * len(tmp)
    
  return (np.array(xSamples), np.array(ySamples))  

Проверяем как отрабатывает функция на тестовых данных:

texts = []
texts.append([x for x in range(23)]) #Тестовый текст 1
texts.append([x for x in range(100, 123)]) #Тестовый текст 2

xTrain, yTrain  = createSetsMultiClasses(texts, 10, 3) 
print(xTrain)
print(yTrain)

[[  0   1   2   3   4   5   6   7   8   9]
 [  3   4   5   6   7   8   9  10  11  12]
 [  6   7   8   9  10  11  12  13  14  15]
 [  9  10  11  12  13  14  15  16  17  18]
 [ 12  13  14  15  16  17  18  19  20  21]
 [100 101 102 103 104 105 106 107 108 109]
 [103 104 105 106 107 108 109 110 111 112]
 [106 107 108 109 110 111 112 113 114 115]
 [109 110 111 112 113 114 115 116 117 118]
 [112 113 114 115 116 117 118 119 120 121]]
[[1. 0.]
 [1. 0.]
 [1. 0.]
 [1. 0.]
 [1. 0.]
 [0. 1.]
 [0. 1.]
 [0. 1.]
 [0. 1.]
 [0. 1.]]

Как говорилось ранее, каждой строке обучающей матрицы ставится в соответствие номер строки в исходном массиве текстов (texts).

Подготовка данных

#Задаём базовые параметры
xLen = 7000 #Длина отрезка текста в результирующемвекторе в словах
shift= 100 #Смещение окна для разбиения исходного текста на обучающие вектора

xTrain, yTrain = createSetsMultiClasses(trainWordIndexes, xLen, shift) #Формируем обучающую выборку
xTest, yTest = createSetsMultiClasses(testWordIndexes, xLen, shift)    #Формируем тестовую выборку
print(xTrain.shape)
print(yTrain.shape)
print(xTest.shape)
print(yTest.shape)

(15369, 7000)
(15369, 7)
(5629, 7000)
(5629, 7)

Конвертируем индексы слов в обучающей матрице в бинарную последовательность Bag of Words.

xTrain01 = tokenizer.sequences_to_matrix(xTrain.tolist()) #Конвертируем xTrain в список перед передачей методу
xTest01 = tokenizer.sequences_to_matrix(xTest.tolist()) #Конвертируем xTest в список перед передачей методу

print(xTrain01.shape)       #Размер обучающей выборки, сформированной по Bag of Words
print(xTrain01[0][100:120]) #фрагмент набора слов в виде Bag of Words

(15369, 20000)
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 0. 1. 1. 1. 0. 1. 1. 0. 1. 1.]

Нейросеть

При обучении полносвязной нейронной сети в качестве активационной функции чаще всего используют «softmax», однако в данном случае использование «sigmoid» позволяет существенно увеличить долю верных ответов на проверочной выборке до 97-99%.

Смысла увеличивать количество слоев или нейронов особо нет, велик риск получить переобучение.

Слой dropout(0.3) снижает вероятность переобучения. При меньших значениях нейронная сеть склонна к переобучению.

Важный момент — активационная функция. Если использовать часто применяемый softmax, то результаты существенно хуже, а если tanh, иногда рассматриваемый как более удачную альтернативу sigmoid, то ошибка возрастает колоссально.

#Создаём полносвязную сеть (FeedForward Neural Network)
model01 = Sequential()
model01.add(BatchNormalization())
model01.add(Dense(30, input_dim=maxWordsCount, activation="relu"))
model01.add(Dropout(0.3))
model01.add(BatchNormalization())
model01.add(Dense(len(trainText), activation='sigmoid'))

model01.compile(optimizer='adam', 
              loss='categorical_crossentropy', 
              metrics=['accuracy'])

#Обучаем сеть на выборке, сформированной по bag of words - xTrain01
history = model01.fit(xTrain01, 
                      yTrain, 
                      epochs=20,
                      batch_size=128,
                      validation_data=(xTest01, yTest))

plt.plot(history.history['acc'], 
         label='Доля верных ответов на обучающем наборе')
plt.plot(history.history['val_acc'], 
         label='Доля верных ответов на проверочном наборе')
plt.xlabel('Эпоха обучения')
plt.ylabel('Доля верных ответов')
plt.legend()
plt.show()

На 9-й эпохе при dropout(0.4) получена точность распознавания 0,9922.

Распознавание

Если нейронная сеть смогла обобщить исходные данные, выделив характерные черты (features) авторов, то созданную модель можно использовать для распознавания текстов автора, на которых сеть не обучалась. Например, в случае Марининой первый том книи использовался для тренировки сети, а второй — для проверки, что модель полученная сетью позволяет ей с некоторой вероятностью идентифицировать автора текста.

Сначала подготовим исходный текст для подачи на полносвязную нейронную сеть. Функция prepareTextForRecognition преобразует текст в последовательность пригодную для распознавания.

def prepareTextForRecognition(testText, xLen, shift): #функция принимает последовательность индексов, размер окна, смещение окна
  testWordIndexes = tokenizer.texts_to_sequences([testText]) 
  print("Source text:",testText[:100])
  print("Indexes:",testWordIndexes[0][:10])
  print(len(testWordIndexes[0]))
  sample = getSetFromIndexes(testWordIndexes[0], xLen, shift)
  print("Number of text blocks of xLen:", len(sample))
  xTest = tokenizer.sequences_to_matrix(sample)
 
  return np.array(xTest)
srcTextBOW = prepareTextForRecognition(testText[3], xLen, step)
#Получаем результаты распознавания класса по блокам слов длины xLen
currPred = model01.predict(srcTextBOW)
print(currPred.shape)
print(currPred[:2])
#Определяем номер распознанного класса для каждого блока слов длины xLen
currOut = np.argmax(currPred, axis=1)

print(currOut)

Результат исполнения кода требует пояснения.

Source text: Зачарованное паломничество    1  Гоблин со стропил следил за прячущимся монахом, который шпионил за
Indexes: [1537, 76, 3406, 22, 133, 22, 14059, 18330, 1, 977]
42715
Number of text blocks of xLen: 358
(358, 7)
[[1.6987324e-05 1.8984079e-05 6.2286854e-06 3.5908818e-04 2.3365021e-05
  2.4348497e-05 2.4139881e-06]
 [1.9401312e-05 2.1964312e-05 5.7220459e-06 3.5190582e-04 2.7537346e-05
  2.3245811e-05 2.3245811e-06]]
[3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 1 1 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 5 5 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3]

После подачи предобработанной последовательности на нейронную сеть на выходе получается матрица 358 х 7, где 358 — это количество блоков текстов длиной xLen, а 7 — количество классов для определения.

Нейронка производит классификацию по каждому из 358 блоков, голосуя вероятностями. Если один текст является плагиатом, то вероятности будут достаточно близкими. Также если нейронке для проверки «скормить» текст, на котором она не обучалась, то вероятности предсказания по идее должны быть близки без явного лидера. Хотя эту гипотезу надо проверять. Но это тема другого исследования.

Метод argmax() с указанием axis=1 говорит, что искать максимальное значение нужно по строкам, т.е. в каждой строке будет найден максимум и вернется его индекс в строке. Пример использования argmax:

arr = np.array([[0.1, 0.3, 0.2, 0.6, 0.9],
                [0.7, 0.3, 0.2, 0.6, 0.5],
                [0.8, 0.3, 0.9, 0.6, 0.3]])
print(np.argmax(arr, axis=1))

[4 0 2]

Визуально заметно, что по большинству блоков строк нейронная сеть отнесла их к классу 3. Если посчитать вероятности, то:

probability = [0]*nClasses

for i in range(len(testText)): #Проходим по всем 7 классам и считаем вероятность отнесения текста к каждому из классов
  probability[i] = np.count_nonzero(currOut == i)/len(currOut)

print(probability)

recognizedClass = np.argmax(probability)
print("Текст написан:", className[recognizedClass], "с вероятностью", probability[recognizedClass])

[0.0, 0.00558659217877095, 0.002793296089385475, 0.9860335195530726, 0.0, 0.00558659217877095, 0.0]
Текст написан: Саймак с вероятностью 0.9860335195530726

В notepad дается функция рассчитывающая вероятность предсказания каждого автора текста. Я не буду приводить код функции, после разбора сделанного выше разобраться по notebook несложно. Результат определения текстов авторов:

О. Генри  распознано  100 % сеть считает, что это  О. Генри ,  распознано ВЕРНО!
Стругацкие  распознано  100 % сеть считает, что это  Стругацкие ,  распознано ВЕРНО!
Булгаков  распознано  100 % сеть считает, что это  Булгаков ,  распознано ВЕРНО!
Саймак  распознано  98 % сеть считает, что это  Саймак ,  распознано ВЕРНО!
Фрай  распознано  84 % сеть считает, что это  Фрай ,  распознано ВЕРНО!
Брэдбери  распознано  100 % сеть считает, что это  Брэдбери ,  распознано ВЕРНО!
Маринина  распознано  100 % сеть считает, что это  Маринина ,  распознано ВЕРНО!

Средний процент распознавания  95 %

Полезные ссылки

Spread the love
Запись опубликована в рубрике IT рецепты, IT решения для бизнеса. Добавьте в закладки постоянную ссылку.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *