Время прочтения: 8 мин.
Задача распознавания именованных сущностей (Named-entity recognition или NER) – одна из часто встречаемых при обработке текста методами NLP – выделить из текста фрагменты, которые соответствуют этим самым именованным сущностям. В формальном виде понятие именованной сущности было сформулировано на конференции MUC-6 в 1995 году. Тогда под ними подразумевались только персоны, локации и организации. На сегодняшний день этот перечень пополнился денежными суммами в комплекте с наименованием валюты, датами, марками различной продукции.
Пример решения задачи NER представлен на иллюстрации ниже:
Практическая польза решения задачи NER кажется очевидной. Выделение именованных сущностей – один из главных шагов на пути к пониманию текста. Анализируя текст договора с помощью этого метода, можно выявить материально заинтересованные стороны. Обрабатывая записи логов платёжной системы, можно понять кто, в каком количестве и валюте вносил денежные средства.
Как и у любой задачи NLP, у NER есть свои подводные камни. Самый известный из них наглядно иллюстрируется анекдотом:
«-Как найти площадь Ленина?
-Надо длину Ленина умножить на ширину Ленина».
Здесь слово «Ленин» представлено в составе двух сущностей: локации и персоны. Задача любой модели, решающей проблему NER, не только выделить саму сущность, но и правильно определить её тип.
Сегодня самым качественным инструментом для решения задачи NER является LSTM – сети с долгой краткосрочной памятью. Это модификация рекуррентных нейронных сетей (RNN), особенность которых в способности обучаться долговременным зависимостям. Эта особенность позволяет им анализировать текст, опираясь не только на конкретное слово, которое сеть «видит» в данный момент, но и на взаимосвязь этого слова с «увиденными» ранее в рамках того же текста. Таким образом, модель учится понимать контекст. Это обеспечивает высокую точность работы модели.
Но что делать, если решение задачи NER необходимо в сжатые сроки, и на написание и обучение нейронной сети просто нет времени?
DISCLAIMER: Все ФИО, названия организаций и адреса почты сгенерированы искусственно. Любые совпадения с реальными лицами, организациями и существующими адресами электронной почты случайны!
Пусть у нас есть файл, в котором собраны почтовые адреса. В нашем случае их будет 223. Часть из них принадлежат физическим лицам, часть – организациям.
USERNAME | POST | IS_HUMAN |
Сагадиев Мстислав Яковович | singh@outlook.com | 1 |
Яблонова Ксения Федотовна | bmorrow@sbcglobal.net | 1 |
Радыгина Владислава Яновна | csilvers@hotmail.com | 1 |
Ярцин Никон Денисович | ramollin@msn.com | 1 |
Жцикка-Консалтинг | devans@malone.com | 0 |
Шошси-Стартап | bburke@brown.com | 0 |
ООО Кадазмё | gabriel01@yahoo.com | 0 |
ЗАО Сила Вернадского | vernad@gmail.com | 0 |
IVAN IVANOVICH IVANOV | imagination_problem@gmail.com | 1 |
Наша задача будет заключаться в том, чтобы с помощью NER распознать тип сущности: «персона» или «организация» и маркировать те адреса, которые принадлежат физическим лицам, единицей. Для проверки качества работы алгоритмов сделаем разметку ответов, в качестве меры точности будем использовать f1-меру.
Особое внимание стоит обратить на последние две записи в приведенном фрагменте файла. В «ЗАО Сила Вернадского» слово «Вернадский» соответствует сущности типа «организация», хотя может быть распознано и как «персона» — алгоритм должен уловить это отличие. В «IVAN IVANOVICH IVANOV» очевидно представлена сущность типа «персона», но она написана транслитом. Это должно усложнить работу алгоритму.
Для решения задачи применим четыре инструмента, доступных в языке Python.
- nltk — https://www.nltk.org/
- natasha — https://natasha.readthedocs.io/ru/latest/
- stanza — https://stanfordnlp.github.io/stanza/
- pullenti — https://pullenti.ru/Default.aspx
Подробную информацию о создателях этих библиотек, их особенностях и отличиях можно прочитать, перейдя по указанным ссылкам. Мы же сконцентрируемся на коде решения конкретной задачи.
Для начала, импортируем все необходимые и вспомогательные модули, а также зададим глобальные переменные «morph» для nltk и natasha и глобальную переменную «stanza_nlp» для stanza:
import nltk
import pymorphy2
import pandas as pd
import numpy as np
import re
from tqdm import tqdm
import stanza
from natasha import NamesExtractor
from pullenti_wrapper.processor import Processor, PERSON
from sklearn.metrics import f1_score
stanza.download('ru')
nltk.download('punkt')
stanza_nlp = stanza.Pipeline(lang='ru', processors='tokenize,ner')
morph = pymorphy2.MorphAnalyzer()
Будем работать с таблицей почтовых адресов как с pandas DataFrame. Для каждого из наших алгоритмов определим отдельную функцию, которая будет получать на вход текст из столбца USERNAME. Если выделение сущности типа «персона» прошло успешно, функция вернет строку с этой сущностью. Если выделить сущность типа «персона» не удалось, функция вернёт 0.
Функция для nltk:
def name_recognize_nltk(text):
prob_thresh = 0.4
text = str(text)
result = ''
global morph
for word in nltk.word_tokenize(text):
if re.search("максим", word.lower()):
result += 'Максим ( максим)score:1.0 \\ '
else:
for p in morph.parse(word):
if ('Name' in p.tag and p.score >= prob_thresh) or ('Surn' in p.tag and p.score >= prob_thresh):
result += '{:<12}({:>12})score:{:0.3}'.format(word, p.normal_form, p.score)
result += ' \ '
if result != '':
return result
else:
return 0
Функция для natasha:
def name_recognize_natasha(text):
global morph
extractor = NamesExtractor(morph)
matches = extractor(text)
if matches != None:
result = ''
for index, match in enumerate(matches):
if match != None:
result += str(match.fact)
if result !='':
return result
else:
return 0
Функция для stanza:
def name_recognize_stanza(text):
result = ''
global stanza_nlp
doc = stanza_nlp(text)
for sent in doc.sentences:
for ent in sent.ents:
if ent.type == 'PER':
result += f'entity: {ent.text}\ttype: {ent.type}' + " "
if result != '':
return result
else: return 0
Функция для pullenti:
def name_recognize_pullenti(text):
result = ''
processor = Processor([PERSON])
ner_result = processor(text)
if ner_result.matches != []:
for match in ner_result.matches:
result += str(match)+ ' '
if result != '':
return result
else: return 0
Обратите внимание на функцию для nltk – случай с именем «Максим» разобран отдельно. В nltk задача NER сводится к задаче классификации. Если score, который выдаст модель, будет ниже значения prob_thresh=0.4, сущность будет считаться нераспознанной. Для имени «Максим» эта значение score равно 0.2, поэтому это имя в качестве сущности не будет распознано. Слишком низкое значение prob_thresh скажется на качестве модели: повысится количество ложноположительных срабатываний. Поэтому, чтобы не занижать prob_thresh, но распознать Максима, этот случай был разобран и дописан вручную. Список имён, для которых требуется дополнительная обработка, можно пополнить еще.
Запустим все наши алгоритмы выделения сущностей и оценим точность. Для получения усреднённой оценки вычислим для каждого алгоритма средний f1_score на основе 20 независимых выполнений:
#Считали данные
df = pd.read_excel('Выборка.xlsx')
#Словарь, куда будем записывать f-score после кждого выполнения
f_score_dict ={'NLTK':[], 'NATASHA':[],'STANZA':[],'PULLENTI':[]}
#Применяем алгоритмы
for i in tqdm(range(0,20)):
df['NLTK'] = df.USERNAME.apply(name_recognize_nltk)
df['NATASHA'] = df.USERNAME.apply(name_recognize_natasha)
df['STANZA'] = df.USERNAME.apply(name_recognize_stanza)
df['PULLENTI'] = df.USERNAME.apply(name_recognize_pullenti)
#Создаём вспомогательные столбцы для проверки 1 - сущность распознана, 0 - сущность не распознана
df['NLTK_mark'] = df.NLTK.apply(lambda x: x if x==0 else 1 )
df['NATASHA_mark'] = df.NATASHA.apply(lambda x: x if x==0 else 1 )
df['STANZA_mark'] = df.STANZA.apply(lambda x: x if x==0 else 1 )
df['PULLENTI_mark'] = df.PULLENTI.apply(lambda x: x if x==0 else 1 )
#Вычисляем f-score на каждой итерации и записываем в словарь
f_score_dict['NLTK'].append(f1_score(df.IS_HUMAN,df.NLTK_mark))
f_score_dict['NATASHA'].append(f1_score(df.IS_HUMAN,df.NATASHA_mark))
f_score_dict['STANZA'].append(f1_score(df.IS_HUMAN,df.STANZA_mark))
f_score_dict['PULLENTI'].append(f1_score(df.IS_HUMAN,df.PULLENTI_mark))
Посмотрим на примеры распознанных и нераспознанных сущностей.
В этом примере все четыре алгоритма успешно распознали Мстислава Якововича как сущность типа «персона».
А вот Нону Мефодиевну определить, как человека смогли только два алгоритма из четырёх.
Интереснее всего посмотреть, как обстоят дела с двумя подводными камнями, о которых говорилось в самом начале эксперимента:
Видно, что с каждым конкретным примером успешно справились по два алгоритма. Но здесь особенно хочется выделить алгоритмы stanza и pullenti, которые смогли верно распознать и то, что «ЗАО Сила Вернадского» — это «организация», и то, что «IVAN IVANOVICH IVANOV» — это «персона», пусть и написанная транслитом.
Но это всё частные примеры. Давайте посмотрим на совокупную f1-меру:
# Вычисляем средние значения f1
nltk_f1 = np.mean(f_score_dict['NLTK'])
natasha_f1 = np.mean(f_score_dict['NATASHA'])
stanza_f1 = np.mean(f_score_dict['STANZA'])
pullenti_f1 = np.mean(f_score_dict['PULLENTI'])
print(*['NLTK = ' + str(nltk_f1),'NATASHA = ' + str(natasha_f1),
'STANZA = ' + str(stanza_f1),'PULLENTI = ' + str(pullenti_f1)], sep = '\n')
Выполнив эту команду, получим следующее:
NLTK = 0.8
NATASHA = 0.89
STANZA = 0.97
PULLENTI = 0.98
ВЫВОДЫ
На данном примере мы увидели, что для решения задачи NER вовсе не обязательно писать нейронную сеть. Более того, мы познакомились с несколькими инструментами, позволяющими решить эту задачу, оценили простоту написания кода и качество результатов.
В этом ключе особенно хочется выделить пакет pullenty. Написание кода для него кажется наиболее простым. По сути, всё распознавание происходит всего в двух строчках кода:
processor = Processor([PERSON])
ner_result = processor(text)
Помимо этого, алгоритм продемонстрировал самый высокий средний f1-score, а также справился и с распознаванием контекста для корректного определения типа сущности, и с транслитом.