Analyse de la coloration émotionnelle des critiques de Kinopoisk

Entrée


Le traitement du langage naturel (PNL) est un domaine populaire et important de l'apprentissage automatique. Dans ce hub, je décrirai mon premier projet lié à l'analyse de la coloration émotionnelle des critiques de films écrites en Python. La tâche de l'analyse sentimentale est assez courante chez ceux qui veulent maîtriser les concepts de base de la PNL, et peut devenir un analogue du «Hello world» dans ce domaine.

Dans cet article, nous passerons par toutes les étapes principales du processus Data Science: de la création de votre propre ensemble de données, du traitement et de l'extraction des fonctionnalités à l'aide de la bibliothèque NLTK, et enfin à l'apprentissage et au réglage du modèle à l'aide de scikit-learn. La tâche elle-même consiste à classer les avis en trois classes: négatifs, neutres et positifs.

Formation de Corpus de données


Pour résoudre ce problème, on pourrait utiliser un corps de données prêt à l'emploi et annoté avec des critiques d'IMDB, dont il y en a beaucoup sur GitHub. Mais il a été décidé de créer le vôtre avec des critiques en russe tirées de Kinopoisk. Afin de ne pas les copier manuellement, nous écrirons un analyseur Web. J'utiliserai la bibliothèque de requêtes pour envoyer des requêtes http et BeautifulSoup pour traiter les fichiers html. Tout d'abord, définissons une fonction qui prendra un lien vers les critiques de films et les récupérera. Pour que Kinopoisk ne reconnaisse pas le bot en nous, vous devez spécifier l'argument headers dans la fonction requests.get, qui simulera le navigateur. Il est nécessaire de lui passer un dictionnaire avec les clés User-Agent, Accept-language et Accept, dont les valeurs se trouvent dans les outils de développement du navigateur. Ensuite, un analyseur est créé et les avis sont récupérés à partir de la page, qui sont stockés dans la classe de balisage html _reachbanner_.

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 

Nous nous sommes débarrassés du balisage html, cependant, nos avis sont toujours des objets BeautifulSoup , mais nous devons les convertir en chaînes. La fonction de conversion fait exactement cela. Nous allons également écrire une fonction qui récupère le nom du film, qui sera ensuite utilisée pour enregistrer les critiques.

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

La dernière fonction de l'analyseur prendra un lien vers la page principale du film, une classe de révision et un moyen de sauvegarder les critiques. La fonction définit également les délais entre les demandes qui sont nécessaires pour éviter une interdiction. La fonction contient une boucle qui récupère et stocke les avis à partir de la première page, jusqu'à ce qu'elle rencontre une page inexistante à partir de laquelle la fonction load_data extraira une liste vide et la boucle se rompra.

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

Ensuite, en utilisant le cycle suivant, vous pouvez extraire des critiques de films qui sont dans la liste des urles . Une liste de films devra être créée manuellement. Il serait possible, par exemple, d'obtenir une liste de liens vers des films en écrivant une fonction qui les extrairait des 250 meilleurs films d'une recherche de film, afin de ne pas le faire manuellement, mais 15-20 films suffiraient pour former un petit ensemble de données de mille critiques pour chaque classe. De plus, si vous obtenez une interdiction, le programme affichera sur quel film et quelle classe l'analyseur s'est arrêté pour continuer au même endroit après avoir passé l'interdiction.

 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 

Prétraitement


Après avoir écrit l'analyseur, se rappelant des films aléatoires pour lui et plusieurs interdictions de la recherche de film, j'ai mélangé les critiques dans des dossiers et sélectionné 900 critiques de chaque classe pour la formation et le reste pour le groupe témoin. Maintenant, il est nécessaire de prétraiter le boîtier, à savoir le tokenize et le normaliser. Tokeniser signifie décomposer le texte en composants, en l'occurrence en mots, puisque nous utiliserons la représentation d'un sac de mots. Et la normalisation consiste à convertir les mots en minuscules, à supprimer les mots vides et les bruits excessifs, le stamming et toutes autres astuces qui aident à réduire l'espace des signes.

Nous importons les bibliothèques nécessaires.

Texte masqué
 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 


Nous commençons par définir quelques petites fonctions de prétraitement de texte. Le premier, appelé lower_pos_tag, prendra une liste de mots, les convertira en minuscules et enregistrera chaque jeton dans un tuple avec sa partie de discours. L'opération consistant à ajouter une partie de la parole à un mot est appelée balisage de partie de la parole (POS) et est souvent utilisée dans la PNL pour extraire des entités. Dans notre cas, nous utiliserons des parties du discours dans la fonction suivante pour filtrer les mots.

 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 

Les textes contiennent un grand nombre de mots jugés trop souvent utiles au modèle (les mots dits stop). Fondamentalement, ce sont des prépositions, des conjonctions, des pronoms par lesquels il est impossible de déterminer à quelle classe se réfère le rappel. La fonction propre ne laisse que des noms, des adjectifs, des verbes et des adverbes. Notez qu'il supprime des parties du discours, car elles ne sont pas nécessaires pour le modèle lui-même. Vous pouvez également remarquer que cette fonction utilise le stamming, dont l'essence est de supprimer les suffixes et les préfixes des mots. Cela vous permet de réduire la dimension des signes, car les mots avec des genres et des cas différents seront réduits au même jeton. Il existe un analogue plus puissant du stamming - la lemmatisation, il vous permet de restaurer la forme initiale du mot. Cependant, cela fonctionne plus lentement que le stamming, et, en plus, NLTK n'a pas de lemmatiseur russe.

 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 

Ensuite, nous écrivons la fonction finale qui prendra l'étiquette de classe et récupérera toutes les revues avec cette classe. Pour lire le cas, nous utiliserons la méthode brute de l'objet PlaintextCorpusReader , qui vous permet d'extraire du texte du fichier spécifié. Ensuite, la tokenisation est utilisée RegexpTokenizer, fonctionnant sur la base d'une expression régulière. En plus des mots individuels, j'ai ajouté au modèle des bigrammes, qui sont des combinaisons de tous les mots voisins. Cette fonction utilise également l'objet FreqDist , qui renvoie la fréquence d'occurrence des mots. Il est utilisé ici pour supprimer les mots qui n'apparaissent dans toutes les critiques d'une classe particulière qu'une seule fois (ils sont également appelés hapaks). Ainsi, la fonction renverra un dictionnaire contenant des documents présentés comme un sac de mots et une liste de tous les mots pour une classe particulière.

 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} 

La phase de prétraitement est la plus longue, il est donc logique de paralléliser le traitement de notre dossier. Cela peut être fait à l'aide du module de multitraitement . Dans le prochain morceau de code de programme, je lance trois processus qui traiteront simultanément trois dossiers avec des classes différentes. Ensuite, les résultats seront rassemblés dans un dictionnaire. Ce prétraitement est terminé.

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

Vectorisation


Après avoir prétraité le cas, nous avons un dictionnaire où pour chaque étiquette de classe contient une liste avec des critiques que nous avons tokenisées, normalisées et enrichies avec des bigrammes, ainsi qu'une liste de mots de toutes les critiques de cette classe. Comme le modèle ne peut pas percevoir le langage naturel comme nous le faisons, la tâche consiste maintenant à présenter nos revues sous forme numérique. Pour ce faire, nous allons créer un vocabulaire commun, composé de jetons uniques, et avec lui nous allons vectoriser chaque revue.

Pour commencer, nous créons une liste qui contient les avis de toutes les classes ainsi que leurs étiquettes. Ensuite, nous créons un vocabulaire commun, en prenant dans chaque classe 10 000 des mots les plus courants en utilisant la méthode most_common du même FreqDist . En conséquence, j'ai obtenu un vocabulaire composé d'environ 17 000 mots.

 #     : # [([  ], _)] 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)) 

Il existe plusieurs façons de vectoriser du texte. Les plus populaires d'entre eux: TF-IDF, codage direct et fréquence. J'ai utilisé le codage fréquentiel, dont l'essentiel est de présenter chaque revue comme un vecteur dont les éléments sont le nombre d'occurrences de chaque mot du vocabulaire. NLTK a ses propres classificateurs, vous pouvez les utiliser, mais ils fonctionnent plus lentement que leurs homologues de scikit-learn et ont moins de paramètres. Vous trouverez ci-dessous le code de codage pour NLTK . Cependant, j'utiliserai le modèle Naive Bayes de scikit-learn et encoderai les critiques, en stockant les attributs dans une matrice clairsemée de SciPy et les étiquettes de classe dans un tableau NumPy séparé.

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

Étant donné que dans le jeu de données, les avis avec certaines balises se succèdent, c'est-à-dire tout d'abord neutres, puis tous négatifs et ainsi de suite, vous devez les mélanger. Pour ce faire, vous pouvez utiliser la fonction de lecture aléatoire de scikit-learn . Il convient uniquement aux situations où les signes et les étiquettes de classe sont dans des tableaux différents, car il vous permet de mélanger deux tableaux à l'unisson.

Formation modèle


Il reste maintenant à former le modèle et à vérifier sa précision dans le groupe témoin. Comme modèle, nous utiliserons le modèle du classifieur Naive Bayes. Scikit-learn a trois modèles Naive Bayes en fonction de la distribution des données: binaire, discret et continu. La distribution de nos fonctionnalités étant discrète, nous choisissons MultinomialNB .

Le classifieur bayésien a le paramètre alpha hyper, qui est responsable du lissage du modèle. Naive Bayes calcule les probabilités de chaque revue appartenant à toutes les classes, pour cela multipliant les probabilités conditionnelles d'apparition de tous les mots de revue, à condition qu'ils appartiennent à une classe particulière. Mais si aucun mot de révision n'a été trouvé dans l'ensemble de données de formation, sa probabilité conditionnelle est égale à zéro, ce qui annule la probabilité que la révision appartienne à n'importe quelle classe. Pour éviter cela, par défaut, une unité est ajoutée à toutes les probabilités de mots conditionnelles, c'est -à- dire que alpha est égal à un. Cependant, cette valeur peut ne pas être optimale. Vous pouvez essayer de sélectionner alpha en utilisant la recherche dans la grille et la validation croisée.

 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_ 

Dans mon cas, le foyer de la grille donne la valeur optimale de l'hyperparamètre égale à 0 avec une précision de 0,965. Cependant, cette valeur ne sera évidemment pas optimale pour l'ensemble de données de contrôle, car il y aura un grand nombre de mots qui n'ont pas été trouvés précédemment dans l'ensemble d'apprentissage. Pour un ensemble de données de référence, ce modèle a une précision de 0,598. Cependant, si vous augmentez l' alpha à 0,1, la précision des données d'entraînement chutera à 0,82 et des données de contrôle, elle augmentera à 0,62. Très probablement, sur un ensemble de données plus important, la différence sera plus importante.

 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) 


Conclusion


On suppose que le modèle devrait être utilisé pour prédire les révisions dont les mots n'ont pas été utilisés pour former un vocabulaire. Par conséquent, la qualité du modèle peut être évaluée par sa précision sur la partie contrôle des données, qui est de 0,62. C'est presque deux fois mieux que de deviner, mais la précision est encore assez faible.

Selon le rapport de classification, il est clair que le modèle est le moins performant avec des avis de couleur neutre (précision 0,47 contre 0,68 pour le positif et 0,76 pour le négatif). En effet, les avis neutres contiennent des mots caractéristiques des avis positifs et négatifs. Probablement, la précision du modèle peut être améliorée en augmentant le volume de l'ensemble de données, car le trois millième ensemble de données est plutôt modeste. En outre, il serait possible de réduire le problème à une classification binaire des avis en positifs et négatifs, ce qui augmenterait également la précision.

Merci d'avoir lu.

PS Si vous voulez vous entraîner, mon jeu de données peut être téléchargé sous le lien.

Lien vers l'ensemble de données

Source: https://habr.com/ru/post/fr467081/


All Articles