分析《 Kinopoisk》评论的情感色彩

参赛作品


自然语言处理(NLP)是机器学习的流行且重要领域。 在这个中心,我将描述我的第一个项目,该项目与以Python编写的电影评论的情感色彩分析有关。 情感分析的任务在想要掌握NLP基本概念的人们中很常见,并且可以成为该领域“ Hello world”的类似物。

在本文中,我们将经历数据科学过程的所有主要阶段:从创建自己的数据集,对其进行处理并使用NLTK库提取特征,最后使用scikit-learn学习和调整模型。 任务本身就是将评论分为三类:负面,中立和正面。

数据语料库的形成


为了解决这个问题,可以使用一些现成的,带有注释的数据主体以及IMDB的评论,其中有很多在GitHub上。 但是决定使用Kinopoisk的俄语评论来创建您自己的评论。 为了不手动复制它们,我们将编写一个Web解析器。 我将使用请求库发送http 请求 ,并使用BeautifulSoup处理html文件。 首先,让我们定义一个将链接到电影评论并检索它们的函数。 为了使Kinopoisk无法识别我们中的机器人,您需要在request.get函数中指定headers参数,该函数将模拟浏览器。 必须使用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]) 

解析器的最后一个功能将带有指向电影主页的链接,评论类以及保存评论的方法。 该功能还定义了避免禁令所必需的请求之间的延迟 。 该函数包含一个循环,该循环从第一页开始检索并存储评论,直到遇到不存在的页面为止, 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,它将获取一个包含单词的列表,将其转换为小写,然后将每个标记及其词性保存到一个元组中。 将词性添加到单词的操作称为词性(POS)标记,通常在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函数仅保留名词,形容词,动词和副词。 请注意,由于模型本身不需要这些部分,因此它会删除部分词性。 您还可以注意到该函数使用了填充功能,其实质是从单词中删除后缀和前缀。 这使您可以减小符号的尺寸,因为具有不同属和大小写的单词将被简化为相同的标记。 有一个更强大的词干比喻-lemmatization,它使您可以恢复单词的初始形式。 但是,它的工作速度比填塞要慢,此外,NLTK还没有俄语lemmatizer。

 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 

接下来,我们编写最终函数,该函数将带有类标签并检索该类的所有评论。 为了阅读案例,我们将使用PlaintextCorpusReader对象的raw方法,该方法允许您从指定的文件中提取文本。 接下来,在正则表达式的基础上使用RegexpTokenizer进行标记化。 除了单个单词之外,我还在模型bigrams中添加了所有相邻单词的组合。 此函数还使用FreqDist对象,该对象返回单词出现的频率。 它在此处用于删除在特定类别的所有评论中仅出现一次的单词(它们也称为hapaks)。 因此,该函数将返回一个字典,其中包含以单词袋形式呈现的文档以及特定类的所有单词列表。

 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+|[^\w\s]+') 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} 

预处理阶段是最长的,因此并行处理我们的案件是有意义的。 这可以使用多处理模块来完成。 在下一部分程序代码中,我将启动三个进程,这些进程将同时处理具有不同类的三个文件夹。 接下来,将结果收集在一个字典中。 预处理完成。

 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() 

向量化


在对案例进行预处理之后,我们将拥有一本字典,其中的每个类别标签都包含一个列表,其中包含我们对标记进行了标记化,规范化和丰富化的评论,以及该类所有评论中的单词列表。 由于模型无法像我们一样感知自然语言,因此现在的任务是以数字形式显示我们的评论。 为此,我们将创建一个由唯一标记组成的通用词汇表,并通过它对每个评论进行矢量化处理。

首先,我们创建一个列表,其中包含所有类别的评论及其标签。 接下来,我们创建一个通用词汇表,使用同一FreqDistmost_common方法从每个类中提取10,000个最常用的单词。 结果,我得到了约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) 

由于在数据集中带有特定标签的评论会依次出现,也就是说,所有评论都是中性的,然后都是负面的,依此类推,因此您需要将它们混合在一起。 为此,您可以使用scikit-learn中shuffle函数。 它仅适用于符号和类标签位于不同数组中的情况,因为它允许您将两个数组统一使用。

模型训练


现在,仍然需要训练模型并在对照组中检查其准确性。 作为模型,我们将使用朴素贝叶斯分类器的模型。 Scikit-learn根据数据分布具有三种朴素贝叶斯模型:二进制,离散和连续。 由于特征的分布是离散的,因此我们选择MultinomialNB

贝叶斯分类器具有alpha hyper 参数 ,该参数负责平滑模型。 朴素贝叶斯(Naive Bayes)计算属于所有类别的每个评论的概率,如果所有评论单词都属于特定类别,则乘以所有评论单词出现的条件概率。 但是,如果在训练数据集中未找到某个复习词,则其条件概率等于零,这会使该复习属于任何类别的概率无效。 为了避免这种情况,默认情况下,将一个单位添加到所有条件词概率中,即alpha等于1。 但是,此值可能不是最佳的。 您可以尝试使用网格搜索和交叉验证来选择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)。 确实,中立的评论包含正面和负面评论都具有的特征。 可能通过增加数据集的体积来提高模型的准确性,因为第3,000个数据集相对较小。 同样,可以将问题简化为评论的肯定和否定的二进制分类,这也将提高准确性。

感谢您的阅读。

PS:如果您想练习,可以在链接下方下载我的数据集。

链接到数据集

Source: https://habr.com/ru/post/zh-CN467081/


All Articles