Создаем прототип для Sentiment Analysis с помощью Python и TextBlob

МЕНЮ


Главная страница
Поиск
Регистрация на сайте
Помощь проекту
Архив новостей

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



Что важно для команды разработчиков, которая только начинает строить систему, базирующуюся на машинном обучении? Архитектура, компоненты, возможности тестирования с помощью интеграционных и юнит тестов, сделать прототип и получить первые результаты. И далее к оценке трудоемкости, планированию разработки и реализации.
В этой статье речь пойдет как раз о прототипе. Который был создан через некоторое время после разговора с Product Manager: а почему бы нам не «пощупать» Machine Learning? В частности, NLP и Sentiment Analysis?
«А почему бы и нет?» — ответил я. Все-таки занимаюсь backend-разработкой более 15 лет, люблю работать с данными и решать проблемы производительности. Но мне еще предстояло узнать, «насколько глубока кроличья нора».

Выделяем компоненты

Чтобы как-то очертить набор компонентов, реализующих логику нашего ML-ядра, взглянем на простой пример реализации сентимент-анализа, один из множества, доступных на GitHub.

Один из примеров реализации сентимент-анализа на Python
import collections import nltk import os from sklearn import (     datasets, model_selection, feature_extraction, linear_model )  def extract_features(corpus):     '''Extract TF-IDF features from corpus'''     # vectorize means we turn non-numerical data into an array of numbers     count_vectorizer = feature_extraction.text.CountVectorizer(         lowercase=True,  # for demonstration, True by default         tokenizer=nltk.word_tokenize,  # use the NLTK tokenizer         stop_words='english',  # remove stop words         min_df=1  # minimum document frequency, i.e. the word must appear more than once.     )     processed_corpus = count_vectorizer.fit_transform(corpus)     processed_corpus = feature_extraction.text.TfidfTransformer().fit_transform(         processed_corpus)      return processed_corpus  data_directory = 'movie_reviews' movie_sentiment_data = datasets.load_files(data_directory, shuffle=True) print('{} files loaded.'.format(len(movie_sentiment_data.data))) print('They contain the following classes: {}.'.format(     movie_sentiment_data.target_names))  movie_tfidf = extract_features(movie_sentiment_data.data)  X_train, X_test, y_train, y_test = model_selection.train_test_split(     movie_tfidf, movie_sentiment_data.target, test_size=0.30, random_state=42)  # similar to nltk.NaiveBayesClassifier.train() model = linear_model.LogisticRegression() model.fit(X_train, y_train) print('Model performance: {}'.format(model.score(X_test, y_test)))  y_pred = model.predict(X_test) for i in range(5):     print('Review: {review} - Correct label: {correct}; Predicted: {predict}'.format(         review=X_test[i], correct=y_test[i], predict=y_pred[i]     )) 
Разбирать такие примеры — это отдельный челлендж для разработчика. Всего лишь 45 строк кода, и сразу 4 (четыре, Карл!) логических блока:
  1. Загрузка данных для обучения модели (строки 25-26)
  2. Подготовка загруженных данных — feature extraction (строки 31-34)
  3. Создание и обучение модели (строки 36-39)
  4. Тестирование обученной модели и вывод результата (строки 41-45)

Каждый из этих пунктов достоин отдельной статьи. И уж точно требует оформления в отдельном модуле. Хотя бы для нужд юнит-тестирования.

Отдельно стоит выделить компоненты подготовки данных и обучения модели.
В каждый из способов сделать модель точнее вложены сотни часов научного и инженерного труда.

Благо, для того, чтобы быстро начать с NLP, есть готовое решение – библиотеки NLTK и TextBlob. Второе является оберткой над NLTK, которая делает рутинную работу – делает feature extraction из тренировочного набора, а затем обучает модель при первом запросе классификации. Но прежде чем обучить модель, необходимо подготовить для нее данные.

Готовим данные

Загружаем данные

Если говорить о прототипе, то загрузка данных из CSV/TSV файла элементарна. Вы просто вызываете функцию read_csv из библиотеки pandas:

import pandas as pd data = pd.read_csv(data_path, delimiter) 

Но это не будут данные, готовые к использованию в модели.

Во-первых, если немного абстрагироваться от csv-формата, то нетрудно ожидать, что каждый источник будет предоставлять данные со своими особенностями, и, следовательно, нам потребуется некая, зависящая от источника, подготовка данных. Даже для простейшего случая CSV-файла, чтобы просто его распарсить, нам надо знать разделитель.

Кроме этого, следует определить, какие записи являются позитивными, а какие негативными. Конечно, это информация указана в аннотации к датасетам, которые мы хотим использовать. Но дело в том, что в одном случае признаком pos/neg является 0 или 1, в другом – логическое True/False, в третьем — это просто строка pos/neg, а в каком-то случае вообще кортеж целых чисел от 0 до 5. Последнее актуально для случая мультиклассовой классификации, но кто сказал, что такой набор данных нельзя использовать для бинарной классификации? Нужно просто адекватно обозначить границу позитивного и негативного значения.

Мне бы хотелось попробовать модель на разных наборах данных, при этом требуется, чтобы после обучения модель возвращала результат в каком-то одном формате. А для этого следует привести ее разнородные данные к единому виду.

Итак, можно выделить три функции, необходимых нам на этапе загрузки данных:

  1. Подключение к источнику данных – для CSV, в нашем случае это реализовано внутри функции read_csv;
  2. Поддержка особенностей формата;
  3. Предварительная подготовка данных.

Вот так это выглядит в коде.

import numpy as np # linear algebra import pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv) import logging  log = logging.getLogger()  class CsvSentimentDataLoader():      def __init__(self, file_path, delim, text_attr, rate_attr, pos_rates):         self.data_path = file_path         self.delimiter = delim         self.text_attr = text_attr         self.rate_attr = rate_attr         self.pos_rates = pos_rates      def load_data(self):         # Здесь данные вычитываются из csv или tsv файла         data = pd.read_csv(self.data_path, self.delimiter)         data.head()         # Отбрасываются все колонки,         # кроме текста и классифицирующего атрибута                data = data[[self.text_attr, self.rate_attr]]         # Значения классифицирующего атрибута         # приводятся к текстовым значениям ‘pos’ и ‘neg’         data[self.rate_attr] = np.where(             data[self.rate_attr].isin(self.pos_rates), 'pos', 'neg')          return data 

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

Сама загрузка происходит в методе load_data.

Разделяем данные на тестовый и тренировочный наборы

Ок, мы загрузили данные, но нам их еще надо разделить на тренировочный и тестовый наборы.

Делается это функцией train_test_split из библиотеки sklearn. Эта функция может принимать на вход множество параметров, определяющих, как именно этот dataset будет разделен на train и test. Эти параметры значительно влияют на получаемые тренировочные и тестовые наборы, и, вероятно, нам будет удобно сделать класс (назовем его SimpleDataSplitter), который будет управлять этими параметрами и агрегировать в себе вызов этой функции.

from sklearn.model_selection import train_test_split # to split the training and testing data import logging  log = logging.getLogger()  class SimpleDataSplitter():      def __init__(self, text_attr, rate_attr, test_part_size=.3):         self.text_attr = text_attr         self.rate_attr = rate_attr         self.test_part_size = test_part_size          def split_data(self, data):         x = data[self.text_attr]         y = data[self.rate_attr]          x_train, x_test, y_train, y_test = train_test_split(             x, y, test_size = self.test_part_size)          return x_train, x_test, y_train, y_test  

Сейчас этот класс включает в себя простейшую реализацию, которая при разделении будет учитывать только один параметр – процент записей, который следует взять в качестве тестового набора.

Датасеты

Для обучения модели я использовал свободно доступные наборы данных в формате CSV:


И чтобы было еще чуть удобнее, для каждого из датасетов я сделал класс, который загружает данные из соответствующего CSV-файла и разбивает их на тренировочный и тестовый наборы.

import os import collections import logging from web.data.loaders import CsvSentimentDataLoader from web.data.splitters import SimpleDataSplitter, TdIdfDataSplitter  log = logging.getLogger()  class AmazonAlexaDataset():      def __init__(self):         self.file_path = os.path.normpath(os.path.join(os.path.dirname(__file__), 'amazon_alexa/train.tsv'))         self.delim = '	'         self.text_attr = 'verified_reviews'         self.rate_attr = 'feedback'         self.pos_rates = [1]          self.data = None         self.train = None         self.test = None      def load_data(self):         loader = CsvSentimentDataLoader(self.file_path, self.delim, self.text_attr, self.rate_attr, self.pos_rates)         splitter = SimpleDataSplitter(self.text_attr, self.rate_attr, test_part_size=.3)          self.data = loader.load_data()         x_train, x_test, y_train, y_test = splitter.split_data(self.data)          self.train = [x for x in zip(x_train, y_train)]         self.test = [x for x in zip(x_test, y_test)]  

Да, для загрузки данных получилось немного больше, чем 5 строк кода в исходном примере.
Зато теперь появилась возможность создавать новые датасеты, жонглируя источниками данных и алгоритмами подготовки тренировочного набора.

Плюс отдельные компоненты гораздо удобнее при юнит-тестировании.

Тренируем модель

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

class TextBlobWrapper():      def __init__(self):         self.log = logging.getLogger()         self.is_model_trained = False         self.classifier = None      def init_app(self):         self.log.info('>>>>> TextBlob initialization started')         self.ensure_model_is_trained()         self.log.info('>>>>> TextBlob initialization completed')      def ensure_model_is_trained(self):         if not self.is_model_trained:              ds = SentimentLabelledDataset()             ds.load_data()              # train the classifier and test the accuracy             self.classifier = NaiveBayesClassifier(ds.train)             acr = self.classifier.accuracy(ds.test)             self.log.info(str.format('>>>>> NaiveBayesClassifier trained with accuracy {}', acr))                         self.is_model_trained = True          return self.classifier 

Сначала получаем тренировочные и тестовые данные, затем делаем feature extraction, и наконец обучаем классификатор и проверяем точность на тестовом наборе.

Тестируем

При инициализации получаем лог, судя по которому, данные загрузились и модель была успешно обучена. Причем обучена с весьма неплохой (для начала) точностью – 0.8878.

image
Получив такие цифры, я был весьма воодушевлен. Но моя радость, к сожалению, была не долгой. Модель, обученная на этом сете, является непробиваемым оптимистом и в принципе не способна распознавать негативные комментарии. Причина этого — в данных обучающего набора. Количество позитивных отзывов в сете – более 90 %. Соответственно, при точности модели около 88 % негативные отзывы просто попадают в ожидаемые 12% неверных классификаций.

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

Чтобы действительно убедиться в этом, я сделал юнит-тест, который прогоняет классификацию отдельно для 100 позитивных и 100 негативных фраз из другого набора данных — для тестирования я взял Sentiment Labelled Sentences Data Set от Калифорнийского университета.
    @loggingtestcase.capturelogs(None, level='INFO')     def test_classifier_on_separate_set(self, logs):         tb = TextBlobWrapper() # Going to be trained on Amazon Alexa dataset          ds = SentimentLabelledDataset() # Test dataset         ds.load_data()          # Check poisitives         true_pos = 0         data = ds.data.to_numpy()          seach_mask = np.isin(data[:, 1], ['pos'])         data = data[seach_mask][:100]          for e in data[:]:             # Model train will be performed on first classification call             r = tb.do_sentiment_classification(e[0])             if r == e[1]:                 true_pos += 1          self.assertLessEqual(true_pos, 100)         print(str.format('  True Positive answers - {} of 100', true_pos)) 

Алгоритм для тестирования классификации позитивных значений следующий:

  • Загружаем тестовые данные;
  • Берем 100 записей с меткой ‘pos’
  • Каждую из них прогоняем через модель и подсчитываем кол-во верных результатов
  • Выводим итоговый результат в консоль

Аналогично делается подсчет для негативных комментариев.

Результат
image
Как и предполагалось, все негативные комментарии распознались как позитивные. А если натренировать модель на датасете, использованном для тестирования – Sentiment Labelled? Там распределение негативных и позитивных комментариев – ровно 50 на 50.

Меняем код и тест, запускаем
image
Уже что-то. Фактическая точность на 200 записей из стороннего набора – 76%, при точности классификации негативных комментариев – 79%. Конечно, 76% — сгодится для прототипа, но недостаточно для продакшена. Это значит, что потребуется предпринимать дополнительные меры для повышения точности алгоритма. Но это уже тема для другого доклада.

Итоги

Во-первых, получилось приложение с десятком классов и 200+ строк кода, что несколько больше исходного примера на 30 строк. И следует быть честным — это лишь наметки на структуру, первое прояснение границ будущего приложения. Прототип.

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

Следующая вещь, которая может поставить новичка в ступор – данные не менее важны, чем выбранная модель. Это было наглядно показано.

Далее, всегда остается вероятность, что обученная на одних данных модель покажет себя неадекватно на других, или в какой-то момент ее точность начнет деградировать.
Соответственно, требуются метрики контроля состояния модели, гибкость при работе с данными, технические возможности скорректировать обучение на лету. И так далее.

Как по мне, все это следует учитывать еще при проектировании архитектуры и выстраивании процессов разработки.

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


Телеграм: t.me/ainewsline

Источник: habr.com

Комментарии: