Как бы мы предсказывали настроение новых обзоров?

В предыдущей части моего письма я попытался охватить шаги для анализа текстовых данных. Анализ, проведенный в предыдущей части, носит описательный характер. В этой части мы попытаемся объяснить, как мы будем предсказывать настроения новых обзоров. Для этой части я многое изучаю из этой Статьи на Medium.

Ссылка на предыдущую часть написания:
https://sea-remus.medium.com/text-processing-and-classification-intro-part-1-sentiment-analysis-7e22a83e1c4

Краткий обзор части 1

Данные, которые мы хотели бы использовать, имеют следующие столбцы:

Мы понимаем, что настроения в обзорах на самом деле классифицируются по столбцу «Оценка» (что делает моделирование фактически излишним), но в этом тексте предполагается, что у нас нет столбца «Оценка».

Анализируя наши текстовые данные, мы знаем, что на рынке присутствуют 2 настроения. Мы классифицируем эти настроения как положительные и отрицательные. Мы будем использовать стоп-слова для удаления лишних слов, в число которых также войдут «br», «href», «amazon», «продукт», «один», «найти», «вкус», «вкус», «хороший», « купить», «сделать» и «кофе», так как в результате анализа мы обнаружили, что эти слова также являются излишними.
Из предыдущего анализа и EDA мы также окончательно пришли к выводу, что не будем использовать столбец «Сводка», поскольку некоторые пользователи не предоставили сводку своих отзывов.

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

Сначала мы импортируем необходимые пакеты и получаем данные, используя следующий код:

#essentials
import pandas as pd
import numpy as np
import sqlite3
import matplotlib.pyplot as plt
import seaborn as sns
#obtaining the data
con = sqlite3.connect('database.sqlite')
raw = pd.read_sql_query("select ProductId,ProfileName, HelpfulnessNumerator, HelpfulnessDenominator, Time, Text, case when Score >= 4 then 1 else 0 end Sentiment from Reviews", con)
con.close()

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

from sklearn.model_selection import train_test_split
xtrain, xsplit, ytrain, ysplit = train_test_split(raw.drop('Sentiment',axis = 1),
                                                  raw.Sentiment, 
                                                  test_size = 0.3, 
                                                  random_state = 42)
xtest, xval, ytest, yval = train_test_split(xsplit,ysplit,
                                            test_size = 0.5,
                                            random_state = 42)

Мы также определили бы функцию токенизации. Мы бы токенизировали текст после удаления избыточных слов (стоп-слов) и лематизировали текст после токенизации.

Итак, для тех, кто не имеет никакого представления об обработке текста при чтении этого письма:

Токенизация в обработке естественного языка в основном означает разделение предложений (последовательности слов) на слова (или токены). На самом деле мы можем сделать это либо с помощью встроенного строкового метода Python .split(), либо с помощью word_tokenize() из пакета NLTK.

Лемматизация — это процесс получения корня слова. Альтернативой лемматизации является стемминг, который заключается в простом выделении слов. Идея обоих состоит в том, чтобы сокращать слова так, чтобы, если бы мы нашли два слова или более, которые имеют схожие значения, мы считали бы их одним и тем же. Например, у нас есть список таких слов, как [‘играть’, ‘играть’, ‘играть’]. При вычленении или лематизации мы получили бы [‘играть’, ‘играть’, ‘играть’]. В некоторых случаях стемминг лучше, чем лемматизация, а в других случаях все наоборот. Думайте об этом как о нормализации и стандартизации числовых данных.

def tokenize(txt):
    import re
    from wordcloud import STOPWORDS
    import nltk
    from nltk.tokenize import word_tokenize
    from nltk.stem.wordnet import WordNetLemmatizer
    stpwrd = set(STOPWORDS)
    #we then add frequent irrelevant words discovered before
    stpwrd.update(['br','href','amazon','product','one',
                   'find','taste','flavor','good','buy',
                   'make','coffee'])
    removeapos = txt.lower().replace("'",'') #remove any apostrophe
    text = re.sub(r"[^a-zA-Z0-9\s]"," ", removeapos) #sub weird char to space
    words = word_tokenize(text)
    lemma = [WordNetLemmatizer().lemmatize(w) for w in words if w not in
             stpwrd]
    return lemma

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

def preprocess_engineer(data,train = True):
    #this is to handle missing values before using the data
    data['ProfileName'] = data['ProfileName'].apply(lambda x: 'Anonymous' if 
                                                (x == 'nan')|(x == 'NaN')|
                                                (x == 'N/A')|(x == '0')|
                                                (x == '')|(x == '-1')|
                                                (x == 'null')|(x == 'Null')|
                                                (x == 'NA')|(x == 'na')|
                                                (x == 'none')|(x == 'unknown')
                                                else x)
    
   #this is to simulate that after data train we would 
   #face a number of new  reviews
    if train == True:
        countprof = data.ProfileName.value_counts().reset_index()
        countprof.columns = ['ProfileName','ProfReviewCount']
        
        countprod = data.ProductId.value_counts().reset_index()
        countprod.columns = ['ProductId','ProdReviewCount']
        
        countspam = data.groupby(['ProductId',
                                  'ProfileName']).size().reset_index()
        countspam.columns = ['ProductId','ProfileName','SpamReviewCount']
        
        engineered = pd.merge(
                        pd.merge(
                         pd.merge(data,countprof,how='left',on='ProfileName'),
                            countprod,how = 'left', on = 'ProductId'),
                        countspam, how = 'left',on =['ProductId',
                                                     'ProfileName'])
        return engineered      
    if train == False:
        #this is from train
        countproftrain = xtrain.ProfileName.value_counts().reset_index()
        countproftrain.columns = ['ProfileName','ProfReviewCounttrain']
        
        countprodtrain = xtrain.ProductId.value_counts().reset_index()
        countprodtrain.columns = ['ProductId','ProdReviewCounttrain']
        
        countspamtrain = xtrain.groupby(['ProductId',
                                         'ProfileName']).size().reset_index()
        countspamtrain.columns = ['ProductId',
                                  'ProfileName',
                                  'SpamReviewCounttrain']
        #this is from the newly introduced data
        countprof = data.ProfileName.value_counts().reset_index()
        countprof.columns = ['ProfileName','ProfReviewCountadd']
        
        countprod = data.ProductId.value_counts().reset_index()
        countprod.columns = ['ProductId','ProdReviewCountadd']
        
        countspam = data.groupby(['ProductId',
                                  'ProfileName']).size().reset_index()
        countspam.columns = ['ProductId','ProfileName','SpamReviewCountadd']
        
        
        
        #this is to add newly introduced data with train
        #ProfileName
        countproffinal = countprof.merge(countproftrain,
                                         how = 'left',
                                         on ='ProfileName')
        countproffinal.fillna(0,inplace = True)
        countproffinal['ProfReviewCount'] = countproffinal.ProfReviewCounttrain + countproffinal.ProfReviewCountadd
        countproffinal.drop(['ProfReviewCounttrain',
                             'ProfReviewCountadd'],
                             axis = 1,inplace = True)
    
        #ProductId
        countprodfinal = countprod.merge(countprodtrain,
                                         how = 'left',
                                         on = 'ProductId')
        countprodfinal.fillna(0,inplace = True)
        countprodfinal['ProdReviewCount'] = countprodfinal.ProdReviewCounttrain + countprodfinal.ProdReviewCountadd
        countprodfinal.drop(['ProdReviewCounttrain',
                             'ProdReviewCountadd'],
                             axis = 1,inplace = True)
        
        #SpamReviewCount
        countspamfinal = countspam.merge(countspamtrain,how = 'left',on = ['ProductId','ProfileName'])
        countspamfinal.fillna(0,inplace = True)
        countspamfinal['SpamReviewCount'] = countspamfinal.SpamReviewCounttrain + countspamfinal.SpamReviewCountadd
        countspamfinal.drop(['SpamReviewCounttrain',
                             'SpamReviewCountadd'],
                             axis = 1,inplace = True)
        
        engineered = pd.merge(
                        pd.merge(
                            pd.merge(data,countproffinal,
                                     how='left',on='ProfileName'),
                                 countprodfinal,
                                 how = 'left', 
                                 on = 'ProductId'),
                             countspamfinal, 
                             how = 'left',
                             on = ['ProductId','ProfileName'])
        return engineered

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

Завершите создание функций, теперь мы применим функцию.

xtrainpreprocessed = preprocess_engineer(xtrain,train = True)
xtestpreprocessed = preprocess_engineer(xtest,train = False)
xvalpreprocessed = preprocess_engineer(xval,train = False)

Обратите внимание, что мы не использовали токенизацию. Я займусь этим чуть позже.

Текстовые данные в качестве входных данных модели

Прежде чем мы приступим к моделированию, мы должны преобразовать текстовые данные в числовые данные. На данный момент я изучил 2 основных способа сделать это: первый — Bag of Words (BOW), а второй — встраивание Word (Word2Vec, W2V). сильный>. Я не собираюсь глубоко погружаться в теории и то, как это работает, вместо этого я просто расскажу вам, что это такое, поскольку это письмо является лишь введением.

  • По сути, BOW пытается преобразовать слова в ваших текстовых данных в числа, которые показывают, как часто слово появляется в ваших данных. Частота появления слова может быть представлена ​​количеством слов (CountVectorizer) или весом слова (TF-IDF). Вы можете попытаться найти их в Google, чтобы лучше понять их.
  • Word2Vec является альтернативой BOW. Word2Vec, как и его название, пытается преобразовать слово в ваших данных в векторы. Word2Vec использует нейронную сеть для создания векторов, которые представляют наши исходные слова. Есть два способа сделать это, первый — CBOW (Continuous Bag of Words) и Skip-gram.

Скажем, у нас есть это предложение:

я пойду по магазинам

Чтобы объяснить это кратко,
CBOW пытается предсказать слово, глядя на другие слова вокруг (пример: output = «собираюсь», input = [«я», «иду», «шопинг»]).< br /> Skip-gram пытается предсказать другие слова вокруг определенного слова (пример: ввод = «собираюсь», вывод = [«я», «иду», «за покупками»])

Мы будем использовать Word2Vec для преобразования текстовых данных в числовые, потому что одним из недостатков метода Bag of Words является то, что он часто сталкивается с проблемами размерности.

Представьте, что у нас есть кадр данных, как указано выше. Количество слов, которые существуют в ваших данных, становится количеством столбцов в вашем фрейме данных. Фрейм данных выше предполагает, что вы используете CountVectorizer в качестве подхода Bag of Words, поэтому вы можете видеть, что, скажем, для первой строки (первых текстовых данных) ваших данных есть 3 «получить» слова, 1 «это» слово, 4 «out». ” слова и 3 слова “can”. Теперь вы можете себе представить, насколько большими будут входные данные, если мы воспользуемся подходом Bag of Words.
С Word2Vec мы можем справиться с этим, вычислив среднее (среднее) векторов слов в текстовых данных.

Воображаемый кадр данных выше показывает вывод Word2Vec. Вектор нулей означает, что слово не появляется в текстовых данных (например, в 3-х текстовых данных нет «это» и «можно»). Обычно векторы каждой точки данных усредняются. После усреднения количество столбцов уже не равно количеству слов. Вместо этого количество столбцов будет равно размеру вектора.

Преобразование текста в числа

Разобравшись с краткими понятиями, мы теперь будем кодировать сразу несколько вещей.

#function to average
def AverageVectors(wordvectmod,oritokenizedtxt):
    todict = dict(zip(wordvectmod.wv.index_to_key,
                      wordvectmod.wv.vectors))
    #if a text is empty we should return a vector of zeros
    #with the same dimensionality as all the other vectors
    dim = len(next(iter(todict.values())))
    return np.array([np.mean([todict[w] for w in words if w in todict]
                              or 
                             [np.zeros(dim)],axis = 0)
                     for words in oritokenizedtxt
                    ])
w2v = Word2Vec(xtrainpreprocessed.Text.apply(tokenize),
                 sg = 0,
                 hs = 1,
                 seed = 42,
                 vector_size = 128)
#convert to vect average
xtrainvect = AverageVectors(w2v,xtrainpreprocessed.Text.apply(tokenize))
xtestvect = AverageVectors(w2v,xtestpreprocessed.Text.apply(tokenize))
xvalvect = AverageVectors(w2v,xvalpreprocessed.Text.apply(tokenize))

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

#add the remaining features
def vecttodata(aftervec,beforevec):
    df = pd.DataFrame(aftervec)
    df['HelpfulnessNumerator'] = beforevec.reset_index(drop=True)['HelpfulnessNumerator']
    df['HelpfulnessDenominator'] = beforevec.reset_index(drop=True)['HelpfulnessNumerator']
    df['Time'] = beforevec.reset_index(drop=True)['Time']
    df['ProfReviewCount'] = beforevec.reset_index(drop=True)['ProfReviewCount']
    df['ProdReviewCount'] = beforevec.reset_index(drop=True)['ProdReviewCount']
    df['SpamReviewCount'] = beforevec.reset_index(drop=True)['SpamReviewCount'] 
    
    return df
    
xtraindf = vecttodata(xtrainvect,xtrainpreprocessed)
xtestdf = vecttodata(xtestvect,xtestpreprocessed)
xvaldf = vecttodata(xvalvect,xvalpreprocessed)

Модель классификации обучения

При этой возможности я хотел бы использовать классификатор XGBoost для построения модели классификации. Время обучения относительно велико, и на завершение тренировочного процесса ушло около часа. Существуют однородные (каждый атрибут/функция имеет схожую природу с одним и другим, например текст, изображение, видео и т. д.) и гетерогенные (каждый атрибут/функция имеет различную природу для одного и другого, например, возраст с доходом, смешанный набор данных и т. д.) данные. Я пытался использовать XGBoost, потому что он хорош для разнородных данных, но если читатели захотят попробовать построить модель классификации, используя в качестве входных данных только текстовые данные, я рекомендую использовать нейронные сети, потому что это, вероятно, намного быстрее и предпочтительнее для однородных наборов данных.

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

rom xgboost import XGBClassifier
from sklearn.utils.class_weight import compute_sample_weight
sample_weights = compute_sample_weight(
    class_weight='balanced',
    y=ytrain
)
xgb = XGBClassifier(booster = 'gbtree',
                    objective = 'binary:logistic',
                    eval_metric='auc',
                    seed = 42, use_label_encoder = False,
                    num_parallel_tree = 10,
                    n_estimators = 50,
                    verbosity = 2)
xgb.fit(xtraindf,ytrain,sample_weight = sample_weights)
pred = xgb.predict(xtestdf)
from sklearn.metrics import confusion_matrix,classification_report
print(classification_report(pred,ytest, target_names = ['Negative','Positive']))

Окончательная проверка и заключение

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

Вы можете видеть, что отчет о классификации ничем не отличается от отчета о классификации для тестовых данных.

Это письмо, хотя и далекое от совершенства, должно было дать грубый пошаговый процесс того, что мы должны делать, когда хотим манипулировать текстовыми данными. Есть много методов манипулирования текстом, которые я сам до конца не понял и не пытаюсь изучить, но я надеялся, что смогу поделиться этими небольшими знаниями с другими новичками в НЛП.
Как обычно, я надеюсь, что вы, ребята, оставите мне какие-либо советы относительно моего подхода в разделе комментариев, аплодируйте, если вы найдете этот пост полезным. :)

Увидимся в следующий раз!