Named-Entity Recognition (NER) анализ текстов на Keras

Named-Entity Recognition (NER) довольно коряво переводится на русский как распознавание именованных сущностей. Рассмотрим пример обработки текста размеченного фрилансерами для решения задачи NER.

В статье будут использоваться информация полученная на лекциях в курсе «Углубленный курс по текстам (Natural Language Processing)», читаемых в «Университете искуственного интеллекта» Константином Слеповым.

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

Т.е. слова относящиеся к названию организаций размечаются зеленым, те, что говорят о месторасположении — желтым и т.п. Слова , которые несут смысл в соответствии с легендой выделяются фрилансерами определенным цветом. Пример аналогичной разметки для multi-label classification подробно рассматривалась в статье «Разметка текста для multi-label classification нейронной сетью«. Пример разметки текста новстноой статьи:

При категориальном анализе фрагмент текста, например, предложение относят к одной единственной категории. Например, отзыв положительный, отрицательный или нейтральный.

В multi-label classification предсказывается наличие в предложении фрагментОВ текста относящихся к определенным классам. В одном тексте может содержаться несколько (multi-label) классов.

Наиболее ресурсоемкий анализ — named-entity analysis (NER), поскольку каждое слово детектируется на предмет отнесения к ОДНОМУ из списка возможных классов.

ПодходLossActivationОписание
Категориальный анализ (categorical analysis) categorical_crossentropysoftmaxВыбор одной категории. Например, отзыв: положительный, отрицательный или нейтральный.
multi-label classificationbinary_crossentropysigmoidОпределение наличия в тексте фрагментов относящиеся к различным классам (меткам — labels). Например, в предложении говорится о скорости, эмоциях, комфорте, сравнении с другими и т.п.
Named-entity recognitioncategorical_crossentropysoftmaxОтнесение каждого слова в тексте к определенному классу (одному из нескольких)
Сравнение методов анализа текста (NLP)

Подобная разбивка слов по категориям полезна, например, для категоризации документов, например, договоров. Можно автоматически вычленять из текста документа слова соответствующие определенным тегам.

Последовательность шагов, которые нужно проделать с исходным текстом для проведения named-entiry analysis наглядно показаны на рисунке взятом отсюда.

Workflow for named entity recognition. The key steps are as follows: (i) documents are collected and added to our corpus, (ii) the text is preprocessed (tokenized and cleaned), (iii) for training data, a small subset of documents are labeled (SPL = symmetry/phase label, MAT = material, APL = application), (iv) the labelled documents are combined with word embeddings (Word2vec [23]) generated from unlabelled text to train a neural network for named entity recognition, and finally (v) entities are extracted from our text corpus.
Последовательность операций (workflow) для named-entity analysis (NER)

Код для named-entiry analysis (NER)

Самая сложная часть — это парсинг текста и формирование обучающей и проверочной выборки. Основной код для парсинга DOCX файлов такой-же, как в статье для multi-label classification. В этой статье парсинг посложнее. По-прежнему используется библиотека python-docx.

В тексте предложения могут заканчиваться по-разному. Правильный вариант завершения — знаки пунктуации: ‘.’, ‘?’, ‘!’. В реальном тексте может быть много ошибок. Например, в конце параграфа могли забыть поставить знак пунктуации, сразу нажав Enter.

Кроме того нельзя делить предложения по ‘.’, ‘?’, ‘!’, поскольку в этом случае сокращения «ред.», «и.о.», «т.д.», «т.п.» выступят в качестве разделителя предложения. Нужно использовать комбинации ‘.’, ‘?’, ‘!’ и символов ‘ ‘, ‘\n’, ‘\t’. В тексте встречаются и ошибки, когда после ‘.’ забыт ‘ ‘, ‘\n’, ‘\t’. Такие моменты можно отслеживать, посмотрев заглавная или строчная буква идет после знака препинания. Я такие проверки не делал. Строка для split-а регулярным выражением в простейшем случае будет такой:

split_by = '! |\. |\? |!\n|\.\n|\?\n|\.\t|\?\t|\!\t|'

Просто сделать split предложения по split_by не получится, поскольку нужно учитывать разметку сделанную фрилансерами для параллельного формировани x_train и y_train. Текст будет парситься run-ами в DOCX-е (кусками текста с одинаковой разметкой) при этом очень легко некорректно сделать парсинг и какие-то предложения разбить некорректно. Поэтому для проверки общего количества предложений в тексте объединю все параграфы и затем сделаю split, чтобы получить более или менее точную оценку количества предложений для проверки.

#Функция возвращает выправленный текст параграфа и количество предложений в нем
def getNumberOfSentences(para):
  txt = para.strip()
  lines = re.split(split_by, txt) #Делим текст на приложения
  lines = [x.strip() for x in lines if x.strip() != '']

  if (txt != ''):
    if (txt[-1] not in [".", "?", "!"]): #Если в конце параграфа пропущена точка, то считаем, что она забыта и добавляем
      txt += ". "
    else:
      txt += txt[-1] + " " #добавляем пробел, чтобы сплит '. ' отрабатывал корректно
 
  return txt, len(lines)

Расчет количества предложений во всех параграфах:

#Проверка сколько всего предложений должно получится во всем тексте для проверки работы парсера. 
def getNumberOfSentencesInDOCX(filename, sentences_idx = 1):
  f = open(filename, 'rb')
  doc = Document(f)

  paragraph_counter = 1
  text = ''
  sentences_counter = 0
  for paragraph in doc.paragraphs:
    txt, num_of_sentences = getNumberOfSentences(paragraph.text)
    sentences_counter += num_of_sentences
    text += txt
    paragraph_counter += 1

  print("Number of sentences 1:", sentences_counter)

  sentences = re.split(split_by, text) #Делим текст на приложения
  sentences = [x.strip() for x in sentences if x.strip() != '']
  print("Number of sentences 2:", len(sentences))
  print("Number of paragraphs:", paragraph_counter - 1)
  return np.array(sentences), len(sentences)

Для разбивки по категориям сформируем два словаря. Помимо заданных правилами тегов в тексте присутствуют и другие цвета. Они обнаружены при парсинге.

color_meaning = {
                '00ff00': 'Организация, группа', 
                '00ffff': 'Личность',
                'ffff00': 'Локация',
                'ff00ff': 'Дата',
                'd9d9d9': 'Национальность',
                '8e7cc3': 'Звание, профессия, чин', 
                'b4a7d6': 'tag_6', 
                'c27ba0': 'tag_7', 
                'cccccc': 'tag_8', 
                '9900ff': 'tag_9', 
                'efefef': 'tag_10',
                '999999': 'tag_11', 
                'fff2cc': 'tag_12',
                '674ea7': 'tag_13', 
                'ffffff': 'tag_14',
                'b7b7b7': 'tag_15',
                '0':      'any'
               }

index_color = {'00ff00': 0, #Организация, группа 
               '00ffff': 1, #Личность
               'ffff00': 2, #Локация
               'ff00ff': 3, #Дата
               'd9d9d9': 4, #Национальность
               '8e7cc3': 5, #Звание, профессия, чин 
               'b4a7d6': 6, 
               'c27ba0': 7, 
               'cccccc': 8, 
               '9900ff': 9, 
               'efefef': 10,
               '999999': 11, 
               'fff2cc': 12,
               '674ea7': 13, 
               'ffffff': 14,
               'b7b7b7': 15
               }   

Функция по выдергиванию цветов run-а из 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 'yellow' in clr:
    return 'yellow'
  if 'white' in clr:
    return 
  if 'none' in clr:
    return 

  return clr 
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 
     
  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;
  if fill is None:
    fill = correctColor(rPr_xml.highlight_color)
  return str(fill).lower()

Ну и основная функция для парсинга цветовой разметки DOCX файла:

#Функция принимает на вход DOCX файл и номер предложения с которого продолжать добавлять в результирующий массив 
#Возвращает массив размеченный следующим образом:
# Номер предложения   Слово   Тег
def parseDOCX(filename, sentences_idx = 0):
  f = open(filename, 'rb')
  doc = Document(f)

  y_train = []
  x_train = []
  highlight_colors = []
  paragraph_count = 1
  sentences_idx_ = sentences_idx #сохраняем начальный индекс
  sentences_idx += 1 #предложение начиается с 1. В аргумент передается индекс последнего предложения 

  #словарь для сбора статистики по частоте появления тегов в тексте
  tag_counter = {'0':      0,
               '00ff00': 0, #Организация, группа 
               '00ffff': 0, #Личность
               'ffff00': 0, #Локация
               'ff00ff': 0, #Дата
               'd9d9d9': 0, #Национальность
               '8e7cc3': 0, #Звание, профессия, чин 
               'b4a7d6': 0, 
               'c27ba0': 0, 
               'cccccc': 0, 
               '9900ff': 0, 
               'efefef': 0,
               '999999': 0, 
               'fff2cc': 0,
               '674ea7': 0, 
               'ffffff': 0,
               'b7b7b7': 0
               } 

  end_of_sentence = [".", "?", "!"] #символы завершения преждложения
  splitters = ' |\n|\t|\r|' #символы разделяющие одно предожение от другого. Может быть ситуация, когда '.' и далее не следует разделитель
  text = []
  for paragraph in doc.paragraphs:
    new_run = ''
    
    number_of_sentences_by_runs = sentences_idx
    for run in paragraph.runs:
      color = getShadingColor(run.font) #получаем цвет очередного run-а
      if (color is not None):
        highlight_colors.append(color)   #добавляем новый цвет в массив для поиска ошибок в разметке. Нужно добавить проверку по словарю тегов для оптимизации памяти. 
        clr_meaning = color_meaning[color] #Определяем значение цвета для добавления в массив
      else: 
        clr_meaning = '0';
        color = '0'

      new_run = run.text.strip() #очищаем текст run-а  
      if (len(new_run) > 1):
        if (new_run[0] in end_of_sentence) and (new_run[1] in splitters) : #Если Run начинается с знака окончания предложения + ' ', инкрементируем номер предложения.
          sentences_idx += 1
          new_run = new_run[2:] #убираем найденный знак препинания.

      words = re.split(splitters, new_run) #делим предложение на слова.  
      for word in words:
        word = word.strip() #очищаем предложение от обрамляющих ' ' и пр. 

        if (word.strip() != ''):
          tag_counter[color] += 1 #инкремент tag для статистики 
          text.append([sentences_idx, word, clr_meaning]) #даже если word содержит знаки препинания, они будут удалены tokenizer-ом в дальнейшем

          if (word[-1] in end_of_sentence): #если в конце слова один из символов end_of_sentence, то считаем, что это конец предложения и делаем инкремент индекса предложений
            sentences_idx += 1

    if (new_run != '') and (new_run[-1] not in end_of_sentence): #если последний run в параграфе не закончился символом окончания предложения, 
      sentences_idx += 1                                         #то считаем, что это ошибка, забыли поставить знак препинания и делаем инкремент индекса предложения 

    str, num_of_sentences = getNumberOfSentences(paragraph.text) #количество строк в предложении посчитанное простым способом. Это проверка для поиска ошибок в параграфах
    if (num_of_sentences != sentences_idx - number_of_sentences_by_runs): #Определение параграфов с некорректным форматированием, например, когда пропущен пробел после '.'
      print("Количество предожений в параграфе:", num_of_sentences, sentences_idx - number_of_sentences_by_runs)
      print(paragraph.text)

    paragraph_count = paragraph_count + 1  

  highlight_colors = np.unique(np.array(highlight_colors)) #Вывод всех найденных цветов в тексте для поиска некорректных разметок текста
  if (len(highlight_colors) > 0):
    print("Colors in the text:", highlight_colors, "\n")

  printTagCounter(tag_counter)
  num_of_sentences = sentences_idx - sentences_idx_
  paragraph_count -= 1
  print("Всего параграфов:", paragraph_count)
  print("Всего предложений:", num_of_sentences)

  return np.array(text), sentences_idx, num_of_sentences

После запуска парсера получаем:

text_1, sentence_idx_1, num_of_sentences_1 = parseDOCX('News_base_part_1.docx')
print("Ранее было найдено:", total_num_of_sentences)

Если найдено несовпадение количества предложений в параграфе, то это, как правило говорит о орфографических ошибках или некорректной разметке. Таких проблем найдено немного. Примитивный способ парсинга дал 8243 предложения, а парсинг run-ами: 8266. Слов с тегами неопределенными в словаре также не очень много. any — это неклассифицированные слова. Можно дописать функцию для формирования списка ошибок для доработки фрилансерами.

Количество предожений в параграфе: 4 5
Министерство иностранных дел Австрии сообщило о своем решении закрыть посольства в Латвии, Литве, ...
Количество предожений в параграфе: 5 6
РФ и Палестина проработают вопрос поставок российской ...
Colors in the text: ['00ff00' '00ffff' '674ea7' '8e7cc3' '9900ff' '999999' 'b4a7d6' 'c27ba0' 'cccccc' 'd9d9d9' 'efefef' 'ff00ff' 'fff2cc' 'ffff00' 'ffffff'] 

any: 132551
Организация, группа: 6637
Личность: 7171
Локация: 9803
Дата: 3600
Национальность: 1733
Звание, профессия, чин: 4662
tag_6: 9
tag_7: 1
tag_8: 187
tag_9: 1
tag_10: 4
tag_11: 0
tag_12: 0
tag_13: 8
tag_14: 19
tag_15: 0
Всего параграфов: 1694
Всего предложений: 8266
Ранее было найдено: 8243

После конкатенации нескольких текстов погружаем их в Pandas DataFrame:

df = pd.DataFrame(data = text_all, columns = ['sentence_idx', 'word','tag'])
df['sentence_idx'] = pd.to_numeric(df['sentence_idx'])
df['word'] = df['word'].astype(str)
df['tag'] = df['tag'].astype(str)
df.head()

Получаем последний номер предложения:

max_idx = int(df.sentence_idx.max())
max_idx

или

max_idx = text_all[-1,0].astype(int)
max_idx
#Код из разряда "исторически сложилось" :-) Можно легко собрать массивы для обучения в функции parseDOCX. 
sequences = []
tags = []
for i in range(1, max_idx+1):
  sequences.append(df.loc[df['sentence_idx']==i, 'word'].values.tolist())
  tags.append(df.loc[df['sentence_idx']==i, 'tag'].values.tolist())
print(sequences[205])
print(tags[205])

['По', 'всей', 'видимости,', 'они', 'были', 'нацелены', 'против', 'молящихся', 'и', 'путешественников']
['0', '0', '0', '0', '0', '0', '0', '0', '0', 'Звание, профессия, чин']

Собираем для токенизатора слова в предложения:

sequences_ = [' '.join(sequence) for sequence in sequences]
sequences_[105]
num_words = 5000
sent_len = 100
tokenizer = Tokenizer(num_words)
tokenizer.fit_on_texts(sequences_)

X = tokenizer.texts_to_sequences(sequences_)
X = pad_sequences(X, sent_len)

И то-же делаем с тегами:

tags = [' '.join(tag) for tag in tags]
tag_tokenizer = Tokenizer(df.tag.nunique(), filters=' ')
tag_tokenizer.fit_on_texts(tags)
tag_tokenizer.index_word
Y = tag_tokenizer.texts_to_sequences(tags)
Y = pad_sequences(Y, sent_len, value=1)
Y.shape

Y = to_categorical(Y, df.tag.nunique()+1)
Y.shape

Ну и сама нейронка для NER:

emb_size = 200

input = Input(shape=(None,))
x = Embedding(num_words, emb_size)(input)
x = Bidirectional(LSTM(emb_size, return_sequences=True))(x)
x = LSTM(emb_size * 2, return_sequences=True)(x)
x = Dense(df.tag.nunique(), activation='softmax')(x)
#output = CRF()(x)

model = Model(input, x)

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

model.fit(X, Y, batch_size = 32, epochs = 10, validation_split=0.2)

Данных достаточно много, они неплохо сбалансированы. Точность на проверочной выборке val_accuracy: 0.9862, что очень неплохо.

Проверка после предикта

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

def class_report(y_true, y_pred, index_word):     # Функция разбивки предсказания по классам
  true = np.argmax(y_true, axis = -1).flatten()   # Перобразуем в вектор токенов из ohe
  pred = np.argmax(y_pred, axis = -1).flatten()  
  for index, word in index_word.items():          # Проходим по всем классам
    index = int(index) -1
    if index in true:                             
      mask = pred == index                        # Ищем положительные предсказания
      acc = (true[mask] == pred[mask]).mean()
      print('{}  --- {}' .format(word, acc))
    else:
      print('no acc for', word)

Либо функция из библиотеки Sklearn:

from sklearn.metrics import classification_report

classification_report(Ytest_BIOES.argmax(axis=-1).flatten(), Ypred.argmax(axis=-1).flatten(), target_names=list(tag_tokenizer.index_word.values()))

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

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

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