Named-Entity Recognition (NER) довольно коряво переводится на русский как распознавание именованных сущностей. Рассмотрим пример обработки текста размеченного фрилансерами для решения задачи NER.
В статье будут использоваться информация полученная на лекциях в курсе «Углубленный курс по текстам (Natural Language Processing)», читаемых в «Университете искуственного интеллекта» Константином Слеповым.
Рассмотрю пример разметки сделанной фрилансерами новостей и суть NER станет понятна. Есть некоторый текст, который фрилансеры размечают в соответствии с некоторой легендой. Легенда может быть такой:
Т.е. слова относящиеся к названию организаций размечаются зеленым, те, что говорят о месторасположении — желтым и т.п. Слова , которые несут смысл в соответствии с легендой выделяются фрилансерами определенным цветом. Пример аналогичной разметки для multi-label classification подробно рассматривалась в статье «Разметка текста для multi-label classification нейронной сетью«. Пример разметки текста новстноой статьи:
При категориальном анализе фрагмент текста, например, предложение относят к одной единственной категории. Например, отзыв положительный, отрицательный или нейтральный.
В multi-label classification предсказывается наличие в предложении фрагментОВ текста относящихся к определенным классам. В одном тексте может содержаться несколько (multi-label) классов.
Наиболее ресурсоемкий анализ — named-entity analysis (NER), поскольку каждое слово детектируется на предмет отнесения к ОДНОМУ из списка возможных классов.
Подход | Loss | Activation | Описание |
Категориальный анализ (categorical analysis) | categorical_crossentropy | softmax | Выбор одной категории. Например, отзыв: положительный, отрицательный или нейтральный. |
multi-label classification | binary_crossentropy | sigmoid | Определение наличия в тексте фрагментов относящиеся к различным классам (меткам — labels). Например, в предложении говорится о скорости, эмоциях, комфорте, сравнении с другими и т.п. |
Named-entity recognition | categorical_crossentropy | softmax | Отнесение каждого слова в тексте к определенному классу (одному из нескольких) |
Подобная разбивка слов по категориям полезна, например, для категоризации документов, например, договоров. Можно автоматически вычленять из текста документа слова соответствующие определенным тегам.
Последовательность шагов, которые нужно проделать с исходным текстом для проведения named-entiry analysis наглядно показаны на рисунке взятом отсюда.
Код для 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()))