Для тренировки нейронной сети нужен размеченный текст. Нередко разметку текста производят фрилансеры. Файл для разметки может быть подготовлен разными способами. Один из таких вариантов — разметка непосредственно в Word.
Например, есть отзывы о продукте, которые нужно разметить силами фрилансеров. Отзывы экспортируются в Word файл (docx), который представляет собой набор xml файлов содержащих текст с разметкой и сжатый в zip.
Пример разметки:
Слева стоит номер отзыва. Это важно для сверки количества строк после загрузки текста. Это важно для отладки парсера, чтобы понять, что все строки загрузились.
Далее стоит метка (label), например, для обучения распознавать эмоциональную окраску текста (sentiment analys). Например,
- «+» означает положительный отзыв,
- «-» — отрицательный,
- «+-» — нейтральный.
Парсер для разбора такой маркировки текста простой:
def parseAttitude(line): sentiment = 0 line = line.lstrip() #Убираем пробелы слева if line.startswith('+-'): #если отзыв начинается с +- sentiment = 2 #то он нейтральный text = line[2:].strip() #убираем "+-" с начала отзыва elif line.startswith('+'): #если отзыв начинается с + sentiment = 1 #то он позитивный text = line[1:].strip() #убираем "+" с начала отзыва не является обязательным. токенизатор все равно все уберет elif line.startswith('-'): #если отзыв начинается с "-" sentiment = 0 #то он негативный text = line[1:].strip() #убираем "-" с начала отзыва else: #если не начинается с "+", "-" или "+-" sentiment = 3 #то отзыв размечен неверно return text, sentiment
Если в фразе есть несколько разметок, которые относят её к нескольким классам (multi-label classification = многозначная/мультиклассовая/политематическая классификация), то нужно размечать уже блоки текста во фразе. Один из способов такой разметки — выделять тегами, например <a>текст</a>.
В данном примере используется цвет, поскольку позволяет добавлять неограниченное количество меток и можно написать макрос, чтобы фрилансер не забывал какой цвет за какую эмоцию отвечает.
Рассмотрю как на Python парсить такие тексты c помощью библиотеки Python DOCX: https://pypi.org/project/python-docx/ Репозиторий библиотеки на Github.
!pip install python-docx from docx import Document import os import numpy as np def parseDOCX(filename): f = open(filename, 'rb') doc = Document(f) paragraph = doc.paragraphs[0] print(paragraph .text) print("Paragraph XML:", paragraph._p.xml) for run in paragraph .runs: print("Text:", run.text) print("Run:", dir(run)) print("Run style:", dir(run.style)) print("\tRun XML source:", run._r.xml) print("\t", run.style.name) print("\t\tFont name:", run.style.font.name) print("\t\tFont color:", run.style.font.color.rgb) print("\t\tFont highlight:", run.style.font.highlight_color) parseDOCX('car.docx')
В исходном тексте только слово «наслаждается» имеет другую разметку текстом, поэтому проанализируем детальнее только этот блок текста. Остальное уберу и исключу некоторые объемные элементы из тегов.
+ Водитель наслаждается от такого авто и вождения .... Text: наслаждается Run: ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_element', '_parent', '_r', 'add_break', 'add_picture', 'add_tab', 'add_text', 'bold', 'clear', 'element', 'font', 'italic', 'part', 'style', 'text', 'underline'] Run style: ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasshook__', '_element', '_parent', 'base_style', 'builtin', 'delete', 'element', 'font', 'hidden', 'locked', 'name', 'part', 'priority', 'quick_style', 'style_id', 'type', 'unhide_when_used'] Run XML source: <w:r ... w:rsidRPr="00062C99"> <w:rPr> <w:rFonts w:ascii="Roboto" w:eastAsia="Times New Roman" w:hAnsi="Roboto"/> <w:color w:val="262626"/> <w:sz w:val="24"/> <w:szCs w:val="24"/> <w:shd w:val="clear" w:color="auto" w:fill="9900FF"/> <w:lang w:eastAsia="ru-RU"/> </w:rPr> <w:t>наслаждается</w:t> </w:r> Default Paragraph Font Font name: None Font color: None Font highlight: None ....
В разметке есть только две строки влияющие на цвет текста:
<w:color w:val="262626"/> <w:shd w:val="clear" w:color="auto" w:fill="9900FF"/>
Эти атрибуты находятся в теге <w:rPr>. Оберткой для этого тега является класс Font библиотеки OXML. К сожалению, в текущей версии в свойствах класса отсутствует парсер тега Shading <s:shd>. Простой парсер для этого тега:
from docx.oxml.ns import qn #Paragraph shading/background color and foreground pattern are specified with the <w:shd> element. def getShadingColor(rPr_xml): rPr = rPr_xml._element.rPr if rPr is None: return # means no w:rPr element is present results = rPr.xpath('w:shd') if len(results) == 0: return # means no w:shd child element is present shd = results[0] val = shd.get(qn('w:val')) color = shd.get(qn('w:color')) fill = shd.get(qn('w:fill')) return val, color, fill
Результирующий парсер
def parseDOCX(filename): f = open(filename, 'rb') doc = Document(f) paragraph = doc.paragraphs[0] print(paragraph.text) for run in paragraph .runs: print("Text:", run.text) print("\tFont name:", run.font.name) print("\tFont color:", run.font.color.rgb) #run.style.font.color.rgb print("\tFont highlight:", run.font.highlight_color) #print("\tElement:", run.font.element.xml) print("\tShading/background color:", getShadingColor(run.font)) #print("\t\tFont shading:", dir(run.font.element))
+ Водитель наслаждается от такого авто и вождения Text: + Водитель Font color: 262626 Font highlight: None Shading/background color: None Text: наслаждается Font color: 262626 Font highlight: None Shading/background color: ('clear', 'auto', '9900FF') Text: от такого авто и вождения Font color: 262626 Font highlight: None Shading/background color: None
Видно, что у второй вставки в параграф (run) задан shading. Он корректно распарсился.
Парсер цветовой разметки файла DOCX
Для начала зададим некоторую тублицу цветовых кодов. Им будет соответствовать порядковый номер в массиве.
index_color = {'00ff00':0, '00ffff':1, '9900ff':2, 'ff00ff':3, 'ffff00':4, 'green':0, 'yellow':4, 'cyan':1, 'magenta':3, #'neutral':5, 'positive':5, 'negative':6, 'neutral_s':7 }
Для визуализации значений цветовой разметки ещё один словарь:
color_meaning = {'00ff00':'сравнение', '00ffff':'дальность', '9900ff':'эмоции', 'ff00ff':'комфорт', 'ffff00':'скорость', 'green':'сравнение', 'yellow':'скорость', 'cyan':'дальность', 'magenta':'комфорт', 'positive':'позитивный', 'negative':'негативный', 'neutral':'нейтральный', 'neutral_s':'нейтральный' }
Распечатка сентиментов:
def printSentimensCount(sentiment_cnt, prefix = ""): for i in range(len(sentiment_cnt)): key = [k for (k, v) in index_color.items() if v == i] print(prefix + color_meaning[key[0]] + ":", sentiment_cnt[i])
В некоторых случаях название цвета возвращаемое при парсинге DOCX файла отличается от таблицы цветов. Фрилансер может некорректно выбрать цвет, например. Потому простая функция приводит цвета к табличному значению.
def correctColor(color): clr = str(color).lower() if 'green' in clr: return 'green' if 'pink' in clr: return 'magenta' if 'turquoise' in clr: return 'cyan' if 'white' in clr: return None if 'none' in clr: return None return clr
Парсер разметки DOCX файла.
def parseDOCX(filename): f = open(filename, 'rb') doc = Document(f) y_train = [] x_train = [] highlight_colors = [] sentiment_count = [0, 0, 0, 0, 0, 0, 0, 0] count = 1 for paragraph in doc.paragraphs: sentiment_index = [0, 0, 0, 0, 0, 0, 0, 0] text, attitude = parseAttitude(paragraph.text) x_train.append(text) sentiment = "" for run in paragraph.runs: color = getShadingColor(run.font) if color not in index_color: color = correctColor(run.font.highlight_color) if color is not None: highlight_colors.append(color) if color is not None: if sentiment_index[index_color[color]] == 0: sentiment = sentiment + " " + color_meaning[color] sentiment_index[index_color[color]] = 1 sentiment_count[index_color[color]] = sentiment_count[index_color[color]] + 1 sentiment = sentiment.strip() if (len(sentiment) == 0): sentiment = "нейтральный" #sentiment_count[index_color['neutral']] = sentiment_count[index_color['neutral']] + 1 #sentiment_index[index_color['neutral']] = 1 if (attitude == 0): sentiment_count[index_color['negative']] = sentiment_count[index_color['negative']] + 1 attitude_= color_meaning['negative'] sentiment_index[index_color['negative']] = 1 elif (attitude == 1): sentiment_count[index_color['positive']] = sentiment_count[index_color['positive']] + 1 attitude_ = color_meaning['positive'] sentiment_index[index_color['positive']] = 1 elif (attitude == 2): sentiment_count[index_color['neutral_s']] = sentiment_count[index_color['neutral_s']] + 1 attitude_ = color_meaning['neutral_s'] sentiment_index[index_color['neutral_s']] = 1 else: attitude_ = color_meaning['neutral_s'] print(count, sentiment_index, paragraph.text, "[" + sentiment + "]", "[" + attitude_ + "]") y_train.append(sentiment_index) count = count + 1 #print("Highlight color:", highlight_colors) highlight_colors = np.unique(np.array(highlight_colors)) if (len(highlight_colors) > 0): print("Highlight color:", highlight_colors, "\n") printSentimensCount(sentiment_count) return np.array(x_train), np.array(y_train), sentiment_count
Результат работы парсера. Справа первый тег — это характеристика вкраплений в фразу, а второй — эмоциональная окраска самой фразы.
1 [0, 0, 1, 0, 0, 1, 0, 0] + Водитель наслаждается от такого авто и вождения [эмоции] [позитивный] 2 [0, 0, 0, 1, 0, 1, 0, 0] + Красивая машина нужно брать [комфорт] [позитивный]
После токенизатора полезно убрать стопслова не несущие значимого смысла:
def removeStopWords(): #russian_stopwords = stopwords.words("russian") russian_stopwords = ['и', 'в', 'а', 'с', 'но', 'у', 'я', 'же'] print(russian_stopwords) print("Всего токенов:", len(tokenizer.word_index)) print("Токенов до сокращения:", tokenizer.word_index) for w in russian_stopwords: if w in tokenizer.word_index: del tokenizer.word_index[w] del tokenizer.word_docs[w] del tokenizer.word_counts[w] #words = [word for word in tokenizer.word_index if word not in russian_stopwords] #print(words) print("Токенов после сокращения:", len(tokenizer.word_index)) print(tokenizer.word_index) #x_train_1 = tokenizer.texts_to_sequences(x_train1) x_train_tokenized = tokenizer.texts_to_sequences(all_txt) #print(tokenizer.word_index) #print(x_train_1)
После разделения выборки на обучающую и проверочную нужно разделить два типа входных данных на два выхода, чтобы использовать разные типы loss, активационных функций и метрик.
До 5 колонки включительно идут данные для multi-label classification. При решении задачи multi-label classification есть 5 классов и каждый из них может присутствовать во фразе. Функция активации ‘sigmoid’. Loss: ‘binary_crossentropy’. Метрика: AUC(name=’auc’). Для мультиклассовой задачи важная метрика — кривая ROC-AUC.
#Подготовка обучающей и проверочной выборки x_train_, x_test, y_train, y_test = train_test_split(x_train, y_train_all, test_size = 0.2) #, random_state = 42) z_train = y_train[:, 5:8] z_test = y_test[:, 5:8] y_train = y_train[:, :5] y_test = y_test[:, :5]
Важный момент, во фразе могут отсутствовать все классы, т.е. отзыв нейтральный. Не нужно вводить для нейтрального отзыва отдельную колонку и размечать 1-ми, если другие классы отсутствуют. Это может сбивать с толку нейронную сеть, поскольку есть прямая связь между нейтральным значением и всеми остальными, т.е. нейтральное значение — функция от остальных.
Нейтральному значению будут соответствовать 0 во всех колонках, поэтому вводить дополнительную колонку вредно.
После 5-й колонки идут 3 колонки с значениями, которые не могут возникнуть одновременно, т.е. только одно из них: положительный, отрицательный или нейтральный отзыв. Здесь уже loss: ‘categorical_crossentropy’, а функция активации ‘softmax’. Именно поэтому выходы нейронки надо разделять, чтобы можно было использовать разные loss, активационные функции и метрики.
Код нейронки с двумя выходами незамысловатый
embedding_size = 200 word_input = Input(shape=(max_len,)) # creating the embedding word_embedding=Embedding(input_dim = num_words, output_dim = embedding_size, input_length=max_len)(word_input) x = SpatialDropout1D(0.2)(word_embedding) x1 = CuDNNGRU(40)(x) x1 = Dropout(0.2)(x1) x1 = Dense(64,activation = 'relu')(x1) x1 = Dropout(0.2)(x1) x2 = CuDNNGRU(40)(x) x2 = Dropout(0.2)(x2) x2 = Dense(64,activation = 'relu')(x2) x2 = Dropout(0.2)(x2) output_1 = Dense(num_classes, activation = 'sigmoid', name = 'out_1')(x1) #Выход для характерных фраз output_2 = Dense(3, activation = 'softmax', name = 'out_2')(x2) #Выход для эмоций modelGRU_2W = Model(word_input, [output_1, output_2]) modelGRU_2W.summary() losses = {'out_1':'binary_crossentropy', 'out_2':'categorical_crossentropy' } metrics = {'out_1':[AUC(name='auc')], 'out_2':'accuracy' } #modelGRU.compile(loss='binary_crossentropy', metrics=['accuracy'], optimizer=Adam(lr=1e-4)) modelGRU_2W.compile(loss=losses, metrics=metrics, optimizer=Adam(lr=1e-4))
Запуск обучения с передачей соответсвующих numpy array:
out_train = { 'out_1':Y_train, 'out_2':z_train } out_test = { 'out_1':Y_test, 'out_2':z_test } historyGRU = modelGRU_2W.fit(X_train, out_train, batch_size=16, epochs=15, validation_data=(X_test, out_test))
Ну и напоследок функция, которая оценивает точность прогноза сделанного при multi-label classification
def getPredQuality(x_test, y_test, model_for_pred, threshold = 0.1): print("Кол-во строк:", x_test.shape[0]) pred_count = np.zeros(y_test.shape[1]) for p in range(len(pred_count)): pred_count[p] = sum(y_test[:, p] == 1) printSentimensCount(pred_count, "Кол-во отзывов ") pred_precision = np.zeros(y_test.shape[1]) for x in range(len(x_test)): #range(10): # y_pred = model_for_pred.predict(x_test[x].reshape(1, x_test.shape[1]), batch_size=1, verbose = 0)[0] y_pred = 1 * (y_pred >= threshold) #Если значение в предсказании выше некоторого порога, то заменяем на 1 logical_and = 1 * np.logical_and(y_test[x], y_pred) #После логического AND на выходе массив с значениями равными 1, там где совпало #print("\nДо:\t", pred_precision) #print("AND:\t", logical_and) pred_precision = logical_and + pred_precision #print("После\t", pred_precision, "\n") #printSentimensCount(pred_precision) #for p in range(len(pred_precision)): # print(" " + "accurancy:", pred_precision[p]) #print(pred_precision) percentage = np.round((pred_precision / pred_count) * 100, 2) print() printSentimensCount(percentage, "Точность предсказания ")
Точность предсказания сравнение: 52.05 Точность предсказания дальность: 25.0 Точность предсказания эмоции: 73.28 Точность предсказания комфорт: 78.78 Точность предсказания скорость: 28.0