Разметка текста для multi-label classification нейронной сетью

Для тренировки нейронной сети нужен размеченный текст. Нередко разметку текста производят фрилансеры. Файл для разметки может быть подготовлен разными способами. Один из таких вариантов — разметка непосредственно в 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: &lt;w:r ... w:rsidRPr="00062C99">
  &lt;w:rPr>
    &lt;w:rFonts w:ascii="Roboto" w:eastAsia="Times New Roman" w:hAnsi="Roboto"/>
    &lt;w:color w:val="262626"/>
    &lt;w:sz w:val="24"/>
    &lt;w:szCs w:val="24"/>
    &lt;w:shd w:val="clear" w:color="auto" w:fill="9900FF"/>
    &lt;w:lang w:eastAsia="ru-RU"/>
  &lt;/w:rPr>
  &lt;w:t>наслаждается&lt;/w:t>
&lt;/w:r>

	 Default Paragraph Font
		Font name: None
		Font color: None
		Font highlight: None
....

В разметке есть только две строки влияющие на цвет текста:

    &lt;w:color w:val="262626"/>
    &lt;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 &lt;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
Spread the love
Запись опубликована в рубрике IT рецепты с метками , , , . Добавьте в закладки постоянную ссылку.

Обсуждение закрыто.