Анализ данных, Нейронные сети

Экспресс-NER. Или что делать если на нейронку нет времени?

Время прочтения: 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.

  1. nltk — https://www.nltk.org/
  2. natasha — https://natasha.readthedocs.io/ru/latest/
  3. stanza — https://stanfordnlp.github.io/stanza/
  4. 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, а также справился и с распознаванием контекста для корректного определения типа сущности, и с транслитом.

Советуем почитать