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()))