Анализ эмоциональной окраски отзывов с Кинопоиска

в 19:21, , рубрики: natural language processing, nlp, NLTK, python, машинное обучение, обработка естественного языка, сентиментный анализ

Вступление

Обработка естественного языка (NLP) является популярной и важной областью машинного обучения. В данном хабре я опишу свой первый проект, связанный с анализом эмоциональной окраски кино отзывов, написанный на Python. Задача сентиментного анализа является довольно распространенной среди тех, кто желает освоить базовые концепции NLP, и может стать аналогом 'Hello world' в этой области.

В этой статье мы пройдем все основные этапы процесса Data Science: от создания собственного датасета, его обработки и извлечения признаков с помощью библиотеки NLTK и наконец обучения и настройки модели с помощью scikit-learn. Сама задача состоит в классификации отзывов на три класса: негативные, нейтральные и позитивные.

Формирование корпуса данных

Для решения данной задачи можно было бы использовать какой-нибудь уже готовый и аннотированный корпус данных с отзывами с IMDB, коих множество на GitHub. Но было решено создать свой с отзывами на русском языке, взятых с Кинопоиска. Чтобы вручную их не копировать, напишем веб парсер. Для отправки http запросов буду использовать библиотеку requests, а для обработки html файлов BeautifulSoup. Для начала определим функцию, которая будет принимать ссылку на отзывы фильма и извлекать их. Для того, чтобы Кинопоиск не распознал в нас бота, необходимо задать аргумент headers в функции requests.get, который позволит имитировать работу браузера. В него необходимо передать словарь с ключами User-Agent, Accept-language и Accept, значения которых можно найти в инструментах разработчика браузера. Далее создается парсер и извлекаются со страницы отзывы, которые хранятся в классе _reachbanner_ html разметки.

import requests
from bs4 import BeautifulSoup
import numpy as np
import time
import os

def load_data(url):
    r = requests.get(url, headers = headers) # отправка http запроса
    soup = BeautifulSoup(r.text, 'html.parser')# создание html парсера
    reviews = soup.find_all(class_='_reachbanner_')# сохранение только отзывов
    reviews_clean = []
    for review in reviews:# очистка от лишней html разметки
        reviews_clean.append(review.find_all(text=True))
    return reviews_clean

От html разметки мы избавились, однако наши отзывы все еще являются объектами BeautifulSoup, нам же необходимо преобразовать их в строки. Функция convert как раз делает это. Напишем также функцию, которая извлекает имя фильма, которое позже будет использоваться для сохранения отзывов.

def convert(reviews): # преобразование отзывов в строки
    review_converted = []
    for review in reviews:
        for i in review:
            map(str, i)
        review = ''.join(review)
        review_converted.append(review)
    return review_converted
def get_name(url): # получение имени фильма
    r = requests.get(url, headers = headers)
    soup = BeautifulSoup(r.text, 'html.parser')
    name = soup.find(class_='alternativeHeadline')
    name_clean = name.find_all(text = True)
    # Сохранение первого элемента, т. к. извлекается также год фильма
    return str(name_clean[0]) 

Последняя функция парсера будет принимать ссылку на главную страницу фильма, класс отзыва и путь для сохранения отзывов. Также в функции определены задержки (delays) между запросами, которые необходимы для избегания бана. В функции содержится цикл, который извлекает и сохраняет отзывы начиная с первой страницы, пока не встретит несуществующую страницу, из которой функция load_data извлечет пустой список и цикл прервется.

def parsing(url, status, path):
    page = 1
    delays = [11, 12, 13, 11.5, 12.5, 13.5, 11.2, 12.3, 11.8]
    name = get_name(url)
    time.sleep(np.random.choice(delays)) # Выбор рандомной задержки
    while True:
        loaded_data = load_data(url + 'reviews/ord/date/status/{}/perpage/200/page/{}/'.format(status, page))
        if loaded_data == []:
            break
        else:
            # Eсли папок с классами не существует, программа их создаст
            if not os.path.exists(path + r'{}'.format(status)):
                os.makedirs(path + r'{}'.format(status))
            converted_data = convert(loaded_data)
            # Сохранение отзывов
            for i, review in enumerate(converted_data):
                with open(path + r'{}{}_{}_{}.txt'.format(status, name, page, i), 'w', encoding = 'utf-8') as output:
                    output.write(review)
            page += 1
            time.sleep(np.random.choice(delays))

Далее с помощью следующего цикла можно извлечь отзывы с фильмов, которые содержатся в списке urles. Список с фильмами необходимо будет создать вручную. Можно было бы, например, получить список ссылок на фильмы, написав функцию, которая бы извлекала их из топа 250 фильмов кинопоиска, чтобы не делать этого вручную, но для формирования небольшого датасета в тысячу отзывов на каждый класс хватит и 15-20 фильмов. Также если вы получите бан, то программа выведет на каком фильме и классе парсер остановился, чтобы продолжить с того же места после прохождения бана.

path = # путь для сохранения
urles = # список с фильмами
statuses = ['good', 'bad', 'neutral']
delays = [15, 20, 13, 18, 12.5, 13.5, 25, 12.3, 23]
for url in urles:
    for status in statuses:
        try:
            parsing(url = url, status = status, path=path)
            print('one category done')
            time.sleep(np.random.choice(delays))
# Во время бана будет получена ошибка AttributeError
        except AttributeError:
            print('Бан получен: {}, {}'.format(url, status))
            break
# Блок else выполняется тогда, когда внутренний цикл НЕ прерывается
# если внутренний цикл прерывается, то прерывается и внешний
    else:
        print('one url done')
        continue
    break

Предварительная обработка

После написания парсера, вспоминания рандомных фильмов для него и нескольких банов от кинопоиска, я перемешал отзывы в папках и отобрал по 900 отзывов от каждого класса для обучения и оставшиеся для контрольной группы. Теперь необходимо предварительно обработать корпус, а именно его токенизировать и нормализовать. Токенизировать означает разбить текст на составляющие, в данном случае на слова, так как мы будем использовать представление мешка слов. А нормализация заключается в преобразовании в нижний регистр слов, удалении стоп-слов и лишнего шума, стэмминга и любых других приемов, помогающих сократить пространство признаков.

Импортируем необходимые библиотеки.

Скрытый текст

from nltk.corpus import PlaintextCorpusReader
from nltk.stem.snowball import SnowballStemmer
from nltk.probability  import FreqDist
from nltk.tokenize import RegexpTokenizer
from nltk import bigrams
from nltk import pos_tag
from collections import OrderedDict
from sklearn.metrics import classification_report, accuracy_score
from sklearn.naive_bayes import MultinomialNB
from sklearn.model_selection import GridSearchCV
from sklearn.utils import shuffle
from multiprocessing import Pool
import numpy as np
from scipy.sparse import csr_matrix

Начнем с определения нескольких небольших функций для предварительной обработки текста. Первая под названием lower_pos_tag будет принимать список со словами, преобразовывать их к нижнему регистру и сохранять каждую лексему в кортеж с её частью речи. Операция добавления части речи слова называется Part of speech (POS) tagging и часто используется в NLP для извлечения сущностей. В нашем случае мы будем использовать части речи в следующей функции для фильтрации слов.

def lower_pos_tag(words):
    lower_words = []
    for i in words:
        lower_words.append(i.lower())
    pos_words = pos_tag(lower_words, lang='rus')
    return pos_words

В текстах содержится большое число слов, которые встречаются слишком часто, чтобы быть полезными для модели (так называемые стоп слова). В основном это предлоги, союзы, местоимения по которым нельзя определить к какому классу относится отзыв. Функция clean оставит только существительные, прилагательные, глаголы и наречия. Заметьте, что она удаляет части речи, так как для самой модели они не нужны. Также можно заметить, что в этой функции используется стэмминг, суть которого заключается в отбрасывании суффиксов и приставок у слов. Это позволяет сократить размерность признаков, так как слова с разными родами и падежами будут сокращены до одинаковых лексем. Существует более мощный аналог стэмминга – лемматизация, она позволяет восстановить начальную форму слова. Однако работает она медленнее стэмминга, и, помимо этого, в NLTK нет русского лемматизатора.

def clean(words):
    stemmer = SnowballStemmer("russian")
    cleaned_words = []
    for i in words:
        if i[1] in ['S', 'A', 'V', 'ADV']:
            cleaned_words.append(stemmer.stem(i[0]))
    return cleaned_words

Далее напишем финальную функцию, которая будет принимать метку класса и извлекать все отзывы с этим классом. Для чтения корпуса будем использовать метод raw объекта PlaintextCorpusReader, который позволяет извлечь текст из указанного файла. Далее для токенизации используется RegexpTokenizer, работающий на основе регулярного выражения. Помимо отдельных слов я добавил в модель биграммы, представляющие собой комбинации всех соседних слов. Также в этой функции используется объект FreqDist, который возвращает частоту встречаемости слов. Он здесь используется для того, чтобы удалить слова, которые встречаются во всех отзывах определенного класса только один раз (их еще называют гапаксами). Таким образом, функция будет возвращать словарь, содержащий документы, представленные в виде мешка слов и список всех слов для определенного класса.

corpus_root = # Путь к корпусу
def process(label):
    # Wordmatrix - список документов с лексемами
    # All words - список всех слов
    data = {'Word_matrix': [], 'All_words': []}
    # Промежуточный список для удаления гапаксов
    templist_allwords = []
    # Определение пути к папке с определенным лейблом
    corpus = PlaintextCorpusReader(corpus_root + '\' + label, '.*', encoding='utf-8')
    # Получение списка имен файлов в корпусе
    names = corpus.fileids()
    # Создание токенайзера
    tokenizer = RegexpTokenizer(r'w+|[^ws]+')
    for i in range(len(names)): # Обработка корпуса
        bag_words = tokenizer.tokenize(corpus.raw(names[i]))
        lower_words = lower_pos_tag(bag_words)
        cleaned_words = clean(lower_words)
        finalist = list(bigrams(cleaned_words)) + cleaned_words
        data['Word_matrix'].append(final_words)
        templist_allwords.extend(cleaned_words)
    # Определение гапаксов
    templistfreq = FreqDist(templist_allwords)
    hapaxes = templistfreq.hapaxes()
    # Фильтрация от гапаксов
    for word in templist_allwords:
        if word not in hapaxes:
            data['All_words'].append(word)
    return {label: data}

Этап предварительной обработки является самым долгим, поэтому имеет смысл распараллелить обработку нашего корпуса. Сделать это можно с помощью модуля multiprocessing. В следующем куске программного кода я запускаю три процесса, которые будут одновременно обрабатывать три папки с разными классами. Далее результаты будут собраны в один словарь. На этом предварительная обработка закончена.

if __name__ == '__main__':
    data = {}
    labels = ['neutral', 'bad', 'good']
    p = Pool(3)
    result = p.map(process, labels)
    for i in result:
        data.update(i)
    p.close()

Векторизация

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

Начнем с того, что создадим список, в котором будут находится отзывы всех классов вместе с их метками. Далее создадим общий вокабуляр, взяв от каждого класса по 10 000 самых часто встречающихся слов с помощью метода most_common все того же FreqDist. В итоге у меня получился вокабуляр, состоящий примерно из 17 000 слов.

# Создание помеченный данных со структурой:
# [([список слов отзыва], метка_класса)]
labels = ['neutral', 'bad', 'good']
labeled_data = []
for label in labels:
    for document in data[label]['Word_matrix']:
        labeled_data.append((document, label))

# Создание вокабуляра с уникальными лексемами
all_words = [] 
for label in labels:
    frequency = FreqDist(data[label]['All_words']
    common_words = frequency.most_common(10000)
    words = [i[0] for i in common_words]
    all_words.extend(words)
# Извлечение уникальных лексем
unique_words = list(OrderedDict.fromkeys(all_words))

Существует несколько способов векторизовать текст. Наиболее популярные из них: TF-IDF, прямое и частотное кодирование. Я использовал частотное кодирование, суть которого представить каждый отзыв в виде вектора, элементы которого являются числом вхождения каждого слова из вокабуляра. В NLTK есть свои классификаторы, можно использовать и их, но работают они медленнее аналогов из scikit-learn и имеют меньше настроек. Ниже представлен программный код для кодирования для NLTK. Однако я буду использовать модель Наивного Байеса из scikit-learn и закодирую отзывы, сохранив признаки в разреженной матрице из SciPy, а метки классов в отдельном массиве NumPy.

# Частотное кодирование для классификаторов nltk со структурой:
# # [({уникальный термин: кол-во вхождений в документ}, метка класса)]
prepared_data = []
for x in labeled_data:
    d = defaultdict(int)
    for word in unique_words:
        if word in x[0]:
            d[word] += 1
        if word not in x[0]:
            d[word] = 0
    prepared_data.append((d, x[1]))
# Частотное кодирование для классификаторов scikit-learn
# Разреженная матрица для признаков
matrix_vec = csr_matrix((len(labeled_data), len(unique_words)), dtype=np.int8).toarray()
# Массив для меток классов
target = np.zeros(len(labeled_data), 'str')
for index_doc, document in enumerate(labeled_data):
    for index_word, word in enumerate(unique_words):
        # Подсчет кол-ва вхождения слова в отзыв
        matrix_vec[index_doc, index_word] = document[0].count(word)
    target[index_doc] = document[1]
# Перемешиваем датасет
X, Y = shuffle(matrix_vec, target)

Так как в датасете отзывы с определенными метками идут друг за другом, то есть сначала все нейтральные, потом все негативные и так далее, необходимо их перемешать. Для этого можно воспользоваться функцией shuffle из scikit-learn. Она как раз подходит для ситуаций, когда признаки и метки классов находятся в разных массивах, потому что позволяет перемешать два массива в унисон.

Обучение модели

Теперь осталось обучить модель и проверить ее точность на контрольной группе. В качестве модели будем использовать модель Наивного Байесовоского классификатора. В scikit-learn есть три модели Наивного Байеса в зависимости от распределения данных: бинарного, дискретного и непрерывного. Так как распределение наших признаков дискретное, выберем MultinomialNB.

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

parameter = [1, 0, 0.1, 0.01, 0.001, 0.0001]
param_grid = {'alpha': parameter}
grid_search = GridSearchCV(MultinomialNB(), param_grid, cv=5)
grid_search.fit(X, Y)
Alpha, best_score = grid_search.best_params_, grid_search.best_score_

В моем случае грид серч выдает оптимальное значение гиперпараметра равное 0 с точностью в 0.965. Однако такое значение явно не будет оптимальным для контрольного датасета, так как там будет большое число слов, не встречающихся ранее в обучающем наборе. Для контрольного набора данных эта модель имеет точность 0.598. Однако если увеличить alpha до 0.1, точность на обучающих данных упадет до 0.82, а на контрольных возрастет до 0.62. Вероятнее всего, на большем наборе данных разница будет существенней.

model = MultinomialNB(0.1)
model.fit(X, Y)
# X_control, Y_control обработаны так же, как и X и Y
# Однако для векторизации использовался вокабуляр обучающего датасета
predicted = model.predict(X_control)
# Точность на контрольном датасете
score_test = accuracy_score(Y_control, predicted)
# Классификационный отчет
report = classification_report(Y_control, predicted)

Вывод

Предполагается, что модель должна использоваться для прогнозирования отзывов, чьи слова не использовались для формирования вокабуляра. Поэтому качество модели можно оценивать по ее точности на контрольной части данных, которая равна 0.62. Это почти в два раза лучше простого угадывания, но точность все ещё довольно низка.

По классификационному отчету видно, что модель хуже всего справляется с отзывами, имеющими нейтральный окрас (точность 0.47 против 0.68 для положительных и 0.76 для негативных). И действительно, в нейтральных отзывах содержатся слова, характерные как для положительных, так и для отрицательных отзывов. Вероятно, точность модели можно повысить, увеличив объем датасета, так как трехтысячный набор данных является довольно скромным. Также, можно было бы свести задачу до бинарной классификации отзывов на положительные и отрицательные, что также повысило бы точность.

Спасибо за прочтение.

P.S. Если есть желание самому потренироваться, мой датасет можно скачать ниже по ссылке.

Ссылка на датасет

Автор: denizkudryavtsev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js