Analyse der emotionalen Färbung von Rezensionen von Kinopoisk

Eintrag


Natural Language Processing (NLP) ist ein beliebter und wichtiger Bereich des maschinellen Lernens. In diesem Hub werde ich mein erstes Projekt beschreiben, das sich mit der Analyse der emotionalen Färbung von in Python geschriebenen Filmkritiken befasst. Die Aufgabe der sentimentalen Analyse ist unter denen, die die Grundkonzepte von NLP beherrschen wollen, weit verbreitet und kann in diesem Bereich zu einem Analogon der „Hallo Welt“ werden.

In diesem Artikel werden wir alle Hauptphasen des Data Science-Prozesses durchlaufen: von der Erstellung Ihres eigenen Datensatzes über die Verarbeitung und das Extrahieren von Funktionen mithilfe der NLTK-Bibliothek bis hin zum Lernen und Optimieren des Modells mithilfe von Scikit-Learn. Die Aufgabe selbst besteht darin, Bewertungen in drei Klassen einzuteilen: negativ, neutral und positiv.

Datenkorpusbildung


Um dieses Problem zu lösen, könnte man einen vorgefertigten und kommentierten Datenkörper mit Bewertungen von IMDB verwenden, von denen es viele auf GitHub gibt. Es wurde jedoch beschlossen, Ihre eigenen Bewertungen in russischer Sprache von Kinopoisk zu erstellen. Um sie nicht manuell zu kopieren, schreiben wir einen Webparser. Ich werde die Anforderungsbibliothek verwenden, um http- Anforderungen zu senden, und BeautifulSoup , um HTML-Dateien zu verarbeiten. Definieren wir zunächst eine Funktion, die einen Link zu Filmkritiken enthält und diese abruft. Damit Kinopoisk den Bot in uns nicht erkennt, müssen Sie das Header- Argument in der Funktion request.get angeben, das den Browser simuliert. Es ist erforderlich, ein Wörterbuch mit den Schlüsseln User-Agent, Accept-language und Accept zu übergeben, deren Werte in den Browser-Entwicklertools zu finden sind. Als Nächstes wird ein Parser erstellt und Bewertungen von der Seite abgerufen, die in der HTML-Markup-Klasse _reachbanner_ gespeichert sind.

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 

Wir haben das HTML-Markup entfernt, unsere Bewertungen sind jedoch immer noch BeautifulSoup- Objekte, aber wir müssen sie in Zeichenfolgen konvertieren. Die Konvertierungsfunktion macht genau das. Wir werden auch eine Funktion schreiben, die den Namen des Films abruft und später zum Speichern von Rezensionen verwendet wird.

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

Die letzte Funktion des Parsers enthält einen Link zur Hauptseite des Films, eine Überprüfungsklasse und eine Möglichkeit zum Speichern von Überprüfungen. Die Funktion definiert auch Verzögerungen zwischen Anforderungen, die erforderlich sind, um ein Verbot zu vermeiden. Die Funktion enthält eine Schleife, die Bewertungen ab der ersten Seite abruft und speichert, bis sie auf eine nicht vorhandene Seite stößt, von der die Funktion load_data eine leere Liste extrahiert und die Schleife unterbrochen wird.

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

Anschließend können Sie mithilfe des folgenden Zyklus Rezensionen aus Filmen extrahieren, die in der Urles- Liste enthalten sind. Eine Liste der Filme muss manuell erstellt werden. Es wäre zum Beispiel möglich, eine Liste von Links zu Filmen zu erhalten, indem eine Funktion geschrieben wird, die sie aus den 250 besten Filmen einer Filmsuche extrahiert, um dies nicht manuell zu tun. 15 bis 20 Filme würden jedoch ausreichen, um einen kleinen Datensatz mit tausend Rezensionen für jede Klasse zu bilden. Wenn Sie ein Verbot erhalten, zeigt das Programm außerdem an, für welchen Film und welche Klasse der Parser angehalten hat, um nach dem Verbot an derselben Stelle fortzufahren.

 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 

Vorbehandlung


Nachdem ich den Parser geschrieben und zufällige Filme für ihn und mehrere Verbote aus der Filmsuche zurückgerufen hatte, mischte ich die Rezensionen in Ordnern und wählte 900 Rezensionen aus jeder Klasse für das Training und den Rest für die Kontrollgruppe aus. Jetzt ist es notwendig, das Gehäuse vorzuverarbeiten, nämlich es zu tokenisieren und zu normalisieren. Tokenisieren bedeutet, den Text in Komponenten, in diesem Fall in Wörter, zu zerlegen, da wir die Darstellung einer Worttasche verwenden. Und Normalisierung besteht darin, Wörter in Kleinbuchstaben umzuwandeln, Stoppwörter und übermäßiges Rauschen zu entfernen, zu stottern und andere Tricks anzuwenden, die dazu beitragen, den Platz für Zeichen zu verringern.

Wir importieren die notwendigen Bibliotheken.

Versteckter Text
 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 


Wir definieren zunächst einige kleine Funktionen für die Textvorverarbeitung. Der erste mit dem Namen lower_pos_tag nimmt eine Liste mit Wörtern, konvertiert sie in Kleinbuchstaben und speichert jedes Token in einem Tupel mit seinem Wortbestandteil. Die Operation zum Hinzufügen eines Teils der Sprache zu einem Wort wird als POS-Tagging (Part of Speech) bezeichnet und wird in NLP häufig zum Extrahieren von Entitäten verwendet. In unserem Fall verwenden wir Wortarten in der folgenden Funktion, um Wörter zu filtern.

 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 

Die Texte enthalten eine große Anzahl von Wörtern, die zu oft gefunden werden, um für das Modell nützlich zu sein (die sogenannten Stoppwörter). Grundsätzlich handelt es sich dabei um Präpositionen, Konjunktionen und Pronomen, anhand derer nicht bestimmt werden kann, auf welche Klassenerinnerung sich bezieht. Die Funktion clean hinterlässt nur Substantive, Adjektive, Verben und Adverbien. Beachten Sie, dass Teile der Sprache entfernt werden, da sie für das Modell selbst nicht benötigt werden. Sie können auch feststellen, dass diese Funktion Stamming verwendet, dessen Kern darin besteht, Suffixe und Präfixe aus Wörtern zu entfernen. Auf diese Weise können Sie die Dimension von Zeichen reduzieren, da Wörter mit unterschiedlichen Gattungen und Groß- / Kleinschreibung auf dasselbe Token reduziert werden. Es gibt ein leistungsfähigeres Analogon zum Stottern - die Lemmatisierung, mit der Sie die ursprüngliche Form des Wortes wiederherstellen können. Es funktioniert jedoch langsamer als das Stottern, und außerdem verfügt NLTK nicht über einen russischen Lemmatisator.

 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 

Als nächstes schreiben wir die letzte Funktion, die die Klassenbezeichnung übernimmt und alle Bewertungen mit dieser Klasse abruft. Um den Fall zu lesen, verwenden wir die Raw- Methode des PlaintextCorpusReader- Objekts, mit der Sie Text aus der angegebenen Datei extrahieren können. Als nächstes wird die Tokenisierung RegexpTokenizer verwendet, die auf der Grundlage eines regulären Ausdrucks arbeitet. Zusätzlich zu einzelnen Wörtern habe ich dem Modell Bigrams hinzugefügt, die Kombinationen aller benachbarten Wörter sind. Diese Funktion verwendet auch das FreqDist- Objekt, das die Häufigkeit des Auftretens von Wörtern zurückgibt. Es wird hier verwendet, um Wörter zu entfernen, die in allen Überprüfungen einer bestimmten Klasse nur einmal vorkommen (sie werden auch als Hapaks bezeichnet). Somit gibt die Funktion ein Wörterbuch zurück, das Dokumente enthält, die als eine Tasche von Wörtern und eine Liste aller Wörter für eine bestimmte Klasse dargestellt werden.

 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} 

Die Vorverarbeitungsphase ist die längste, daher ist es sinnvoll, die Bearbeitung unseres Falls zu parallelisieren. Dies kann mit dem Multiprocessing- Modul erfolgen. Im nächsten Programmcode starte ich drei Prozesse, die gleichzeitig drei Ordner mit unterschiedlichen Klassen verarbeiten. Als nächstes werden die Ergebnisse in einem Wörterbuch gesammelt. Diese Vorverarbeitung ist abgeschlossen.

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

Vektorisierung


Nachdem wir den Fall vorverarbeitet haben, haben wir ein Wörterbuch, in dem für jedes Klassenlabel eine Liste mit Bewertungen enthalten ist, die wir mit Bigrams versehen, normalisiert und angereichert haben, sowie eine Liste mit Wörtern aus allen Bewertungen dieser Klasse. Da das Modell die natürliche Sprache nicht so wahrnehmen kann wie wir, besteht die Aufgabe nun darin, unsere Übersichten in numerischer Form darzustellen. Zu diesem Zweck erstellen wir ein gemeinsames Vokabular, das aus eindeutigen Token besteht, und vektorisieren damit jede Überprüfung.

Zunächst erstellen wir eine Liste, die Bewertungen aller Klassen zusammen mit ihren Bezeichnungen enthält. Als nächstes erstellen wir ein gemeinsames Vokabular, das aus jeder Klasse 10.000 der gebräuchlichsten Wörter mit der Methode most_common desselben FreqDist entnimmt . Als Ergebnis erhielt ich einen Wortschatz, der aus ungefähr 17.000 Wörtern bestand.

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

Es gibt verschiedene Möglichkeiten, Text zu vektorisieren. Die beliebtesten von ihnen: TF-IDF, Direkt- und Frequenzcodierung. Ich habe die Frequenzcodierung verwendet, deren Kern darin besteht, jede Überprüfung als Vektor darzustellen, deren Elemente die Anzahl der Vorkommen jedes Wortes aus dem Vokabular sind. NLTK hat seine eigenen Klassifikatoren, Sie können sie verwenden, aber sie arbeiten langsamer als ihre Gegenstücke aus Scikit-Learn und haben weniger Einstellungen. Unten finden Sie den Code für die Codierung für NLTK . Ich werde jedoch das Naive Bayes-Modell von scikit-learn verwenden und die Überprüfungen codieren, wobei die Attribute in einer spärlichen Matrix von SciPy und die Klassenbezeichnungen in einem separaten NumPy- Array gespeichert werden.

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

Da im Datensatz die Bewertungen mit bestimmten Tags nacheinander ablaufen, dh zuerst alle neutral, dann alle negativ usw., müssen Sie sie mischen. Dazu können Sie die Shuffle- Funktion von scikit-learn verwenden . Es ist nur für Situationen geeignet, in denen sich Zeichen und Klassenbezeichnungen in unterschiedlichen Arrays befinden, da Sie zwei Arrays gleichzeitig mischen können.

Modelltraining


Jetzt muss das Modell trainiert und seine Genauigkeit in der Kontrollgruppe überprüft werden. Als Modell verwenden wir das Modell des Naive Bayes-Klassifikators. Scikit-learn verfügt je nach Datenverteilung über drei Naive Bayes-Modelle: binär, diskret und kontinuierlich. Da die Verteilung unserer Funktionen diskret ist, wählen wir MultinomialNB .

Der Bayes'sche Klassifikator verfügt über den Alpha- Hyper- Parameter , der für die Glättung des Modells verantwortlich ist. Naive Bayes berechnet die Wahrscheinlichkeiten jeder Überprüfung, die zu allen Klassen gehört, wobei die bedingten Wahrscheinlichkeiten für das Auftreten aller Überprüfungswörter multipliziert werden, sofern sie zu einer bestimmten Klasse gehören. Wenn jedoch im Trainingsdatensatz kein Überprüfungswort gefunden wurde, ist seine bedingte Wahrscheinlichkeit gleich Null, wodurch die Wahrscheinlichkeit aufgehoben wird, dass die Überprüfung zu einer Klasse gehört. Um dies zu vermeiden, wird standardmäßig allen bedingten Wortwahrscheinlichkeiten eine Einheit hinzugefügt, d. H. Alpha ist gleich eins. Dieser Wert ist jedoch möglicherweise nicht optimal. Sie können versuchen, Alpha mithilfe der Rastersuche und der Kreuzvalidierung auszuwählen.

 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_ 

In meinem Fall gibt der Gitterherd den optimalen Wert des Hyperparameters gleich 0 mit einer Genauigkeit von 0,965 an. Ein solcher Wert ist jedoch offensichtlich nicht optimal für den Kontrolldatensatz, da eine große Anzahl von Wörtern vorhanden ist, die zuvor nicht im Trainingssatz gefunden wurden. Für einen Referenzdatensatz hat dieses Modell eine Genauigkeit von 0,598. Wenn Sie jedoch Alpha auf 0,1 erhöhen, sinkt die Genauigkeit der Trainingsdaten auf 0,82 und der Kontrolldaten auf 0,62. Bei einem größeren Datensatz ist der Unterschied höchstwahrscheinlich signifikanter.

 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) 


Fazit


Es wird davon ausgegangen, dass das Modell verwendet werden sollte, um Bewertungen vorherzusagen, deren Wörter nicht zur Bildung eines Vokabulars verwendet wurden. Daher kann die Qualität des Modells anhand seiner Genauigkeit im Steuerteil der Daten bewertet werden, die 0,62 beträgt. Dies ist fast doppelt so gut wie nur zu raten, aber die Genauigkeit ist immer noch ziemlich niedrig.

Dem Klassifizierungsbericht zufolge ist klar, dass das Modell mit Bewertungen mit einer neutralen Farbe am schlechtesten abschneidet (Genauigkeit 0,47 gegenüber 0,68 für positiv und 0,76 für negativ). In der Tat enthalten neutrale Bewertungen Wörter, die sowohl für positive als auch für negative Bewertungen charakteristisch sind. Wahrscheinlich kann die Genauigkeit des Modells durch Erhöhen des Datensatzvolumens verbessert werden, da der dreitausendste Datensatz eher bescheiden ist. Es wäre auch möglich, das Problem auf eine binäre Klassifizierung von Bewertungen in positive und negative zu reduzieren, was ebenfalls die Genauigkeit erhöhen würde.

Danke fürs Lesen.

PS Wenn Sie sich selbst üben möchten, kann mein Datensatz unter dem Link heruntergeladen werden.

Link zum Datensatz

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


All Articles