Время прочтения: 5 мин.

Работали с текстом? А если текста очень много (обращения клиентов/твиты/обратная связь по продукту)? А если необходимо его классифицировать именно так, как нужно вам, при условии, что набор ключевых слов у вас уже есть – например, при оценке клиентских обращений о работе банка вам надо найти не просто слово «кредит», а более детально: кредитный лимит, кредитование бизнеса, просроченный кредит, удовлетворенность клиента услугой выдачи кредита, негатив клиента при выдаче/обслуживании кредита и т.д.?

Итак, сегодня мы расскажем о быстрореализуемом варианте анализа тональности текста с помощью методов машинного обучения (NLP). Другими словами – будем определять эмоциональный окрас текста. В www это часто называется opinion mining, или добыча мнений, ну или сентиментный анализ – нашу задачу можно называть и так, и так. Быстрореализуемо в данном случае значит: 20 строк кода со временем исполнения около 4 часов и обработкой 600 тыс. отзывов на ПК i7/8GB ОЗУ. Ну что, поехали?

В оригинале задача звучит, как поиск негатива в отзывах о работе банков РФ. И для этого надо найти сами обращения. На этом этапе подробно останавливаться не буду, скажу лишь что различными инструментами парсинга удалось извлечь порядка 600 тыс. отзывов из топ-5 сайтов рунета с обращениями клиентов о работе банков в РФ примерно за 2 дня.

После первичной предобработки данные выглядят так:

Отлично, данные взяли, с данными познакомились. Что дальше? Дальше попробуем:

  1. Избавиться от части ненужных слов, в том числе от слов, в которых меньше 4 символов;
  2. Привести слова к единому регистру;
  3. И унифицировать слова, то есть выделить из слова словоформу. Например, у слова «люди» используемый нами анализатор текста выделил словоформу «человек». В литературе этот процесс называется стемминг – то есть выделение словоформы, без смысловой нагрузки. Не путайте этот процесс с лемматизацией – она как раз учитывает контекст. Часть кода, отвечающая за вышеуказанные пункты предобработки текста, представлена ниже:
def drop_trash(raw_html):
    cleanr = re.compile('[^А-Яа-яЁё]')                                
    clean_text = re.sub(cleanr, ' ', raw_html)                        
    clean_text_without_spaces = re.sub('\s{2,}', ' ', clean_text)     
    return clean_text_without_spaces.lower().lstrip().rstrip()        

for token in iteritems(df.SPLIT_REVIEW_DESCRIPTION1):                           
    res_word = ''                                                               
    for word in enumerate(str(token[1]).split(' ')):                            
        if len(word[1]) > 2 or word[1] == 'не':                                 
            res_word = res_word + word[1] + ' '                                 
    res_word = re.sub('( не )|( нет )|( без )', ' не_', res_word)               
    df.SPLIT_REVIEW_DESCRIPTION1[token[0]] = res_word

Обратите внимание – для сохранения эмоционального окраса частицы «нет» / «не» / «без» присоединены к следующему слову.

На унификации слов необходимо остановиться отдельно — здесь мы использовали модуль pymorphy2:

morph = pymorphy2.MorphAnalyzer()
def word_tokenizer(s):
    t = s.split(' ')
    f = ''
    for j in t:
        m = morph.parse(j)[0]
        if len(m) > 1:
            f = f + m.normal_form + ' '
    return f

Если кратко – то это морфологический анализатор для русского языка, который:

  1. Приводит слово к нормальной форме;
  2. Ставит слово в нужную форму, например, изменяет падеж;
  3. Возвращает грамматическую информацию о слове.

Пример работы модуля pymorphy2:

Итак, применим стемминг к атрибуту датафрейма «SPLIT_REVIEW_DESCRIPTION1», в котором содержатся обращения клиентов:

df.SPLIT_REVIEW_DESCRIPTION1 = df.SPLIT_REVIEW_DESCRIPTION1.apply(lambda x: drop_trash(x)).apply(lambda x: word_tokenizer(x))

Готово! На выходе получили 2 атрибута, один – оригинальные обращения клиентов (“orig”), второй – обращения, обработанные процедурой стемминга (“split_review_description1”):

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

Здесь видно, что мы создаем текстовый файл, в котором самостоятельно прописываем интересующие нас слова, а также придаем им эмоциональный окрас:

  • -1 – негативный окрас, несколько -1 – усиление негативного окраса;
  • +1 – положительный окрас, несколько +1 – усиление позитивного окраса;
  • Нейтральные слова (с весом 0) прописывать не надо – все слова, которых нет в нашем словаре – помечаются как нейтральные.

Т.е. в рамках задачи мы умышленно ищем слова, придающие негатив обращению. Таким образом мы разметили порядка 200 слов, которые наиболее часто встречаются в обращениях.

Здесь также необходимо отметить, что, если у вас нет понимания, какие именно слова вы будете извлекать из текста, то после стемминга можно воспользоваться функцией «Counter» модуля «Collections». Функция покажет вам сколько раз слово встретилось в пуле данных.

Итак, данные нормализовали, словарь разметили – пришла пора искать негатив. Код выглядит так:

num_pos = 0
num_neg = 0
num_neutral = 0
id2sent = {}

for idx, doc in enumerate(df.SPLIT_REVIEW_DESCRIPTION1):
    doc_sent_value = 0

    for token in doc.split(' '):
        if token in sent_dict:
            doc_sent_value += sent_dict[token]

        elif doc_sent_value > 0:
            num_pos += 1
            id2sent[idx] = ['1', doc_sent_value]

        elif doc_sent_value < 0:
            num_neg += 1
            id2sent[idx] = ['-1', doc_sent_value]    
            
        if doc_sent_value == 0:
            num_neutral += 1
            id2sent[idx] = ['0', doc_sent_value]
df['emotion'] = pd.Series('', index=df.index)
df['weight'] = pd.Series('', index=df.index)

for i in range(len(df)):
    df['emotion'].iloc[i]=id2sent[i][0]
    df['weight'].iloc[i]=id2sent[i][1]

Итоговый датасет с уже сформированным окрасом обращений выглядит так:

Готово! Теперь каждый отзыв:

  1. Имеет маркер негатива, если таковой присутствует в тексте обращения (атрибут “emotion”);
  2. Имеет «силу» эмоций, или сальдо негативных/позитивных слов (атрибут “weight”).