Sentiment Analysis mit TF-IDF-Classifier, Teil 2 von 2

Anmerkung: Dieser Artikel ist eine Ergänzung zum its-people-Webinar „Do you speak NLP – Ein Streifzug durch modernes Natural Language Processing mit Python“, gehalten im Oktober 2021 von Markus Zeeb und Uwe Blenz -> Link

Teil 1 finden Sie unter -> Link

Hier im zweiten Teil wollen wir unsere Erkenntnisse zum Thema ‚Document Classification via tf-idf‘ nun auf ein größeres und praxisnaheres Problem anwenden, nämlich: Sentiment Analysis über Amazon Reviews. Was sich dahinter verbirgt und mit welchen Werkzeugen wir dieses Problem angehen können.

Sentiment Analysis ist ein Spezialfall von „document classification“, in dem Dokumente (in unserem Fall Produktreviews von Amazon.com) nur zwei Kategorien „positiv“ und „negativ“ zugeordnet werden. Das Vorgehen hier ist wie folgt:

  1. Amazonreviews laden + ein wenig data cleaning + preprocessing
  2. Vektorisiere Dokumente aus 1. und berechne tf-idf-Scores
  3. Verwende Vektoren aus 2. als Input für Classification

Bis einschließĺich Schritt 2 ist der Ablauf im Grunde derselbe wie im ersten Teil unseres Artikels. Wo wir vorher aber mittels 𝚌𝚊𝚝𝚎𝚐𝚘𝚛𝚒𝚣𝚎()categorize() Labels für unsere Dokumente verteilt haben, haben unsere Dokumente schon Labels, d.h. wir trainieren ein Modell, das auf Grundlage der tf-idf-Scores aus Schritt 2 lernen, welche Dokumente eher positiv und welche eher negativ sind.

Außerdem wollen wir nicht wieder alles „from scratch“ implementieren, sondern ungeniert an praxiserprobten Frameworks und Libraries bedienen 🙂

Schritt 1: Setting the stage

Wie oben beschrieben, wollen wir uns im ersten Schritt unsere Product-Reviews laden:

In [10]:

amazon_reviews = pd.read_csv('data/amazon_misc_products_reviews.csv', nrows=10_000)
amazon_reviews.head()

Out[10]:

IdProductIdUserIdProfileNameHelpfulnessNumeratorHelpfulnessDenominatorratingTimetitletext
01B001E4KFG0A3SGXH7AUHU8GWdelmartian1151303862400Good Quality Dog FoodI have bought several of the Vitality canned d…
12B00813GRG4A1D87F6ZCVE5NKdll pa0011346976000Not as AdvertisedProduct arrived labeled as Jumbo Salted Peanut…
23B000LQOCH0ABXLMWJIXXAINNatalia Corres „Natalia Corres“1141219017600„Delight“ says it allThis is a confection that has been around a fe…
34B000UA0QIQA395BORC6FGVXVKarl3321307923200Cough MedicineIf you are looking for the secret ingredient i…
45B006K2ZZ7KA1UQRSCLF8GW1TMichael D. Bigham „M. Wassir“0051350777600Great taffyGreat taffy at a great price. There was a wid…

Für unsere Zwecke sollen die ersten 10.000 Reviews reichen. Außerdem interessieren uns eigentlich nur die Spalten „rating“, „title“ und „text“ – also weg mit dem Rest!

In [11]:

amazon_reviews = amazon_reviews[['rating', 'title', 'text']]
amazon_reviews.head()

Out[11]:

ratingtitletext
05Good Quality Dog FoodI have bought several of the Vitality canned d…
11Not as AdvertisedProduct arrived labeled as Jumbo Salted Peanut…
24„Delight“ says it allThis is a confection that has been around a fe…
32Cough MedicineIf you are looking for the secret ingredient i…
45Great taffyGreat taffy at a great price. There was a wid…

Das sieht schon besser aus, ist aber noch nicht ganz zufriedenstellend. Zum einen sollen die Dokumente nur als „positive“ oder „negative“ gelabelt sein, d.h. wir wollen die Spalte „rating“ von [1 … 5] abbilden auf „0“ (negative) oder „1“ (positive). Und zum Zweiten stellen Titel und Inhalt zusammen ein „Dokument“ dar, d.h. ich möchte diese beiden Spalten eigentlich gerne in einer einzigen vereinen:

In [12]:

reviews = amazon_reviews[['title', 'text']]
reviews = reviews['title'] + " " + reviews['text']

labels = amazon_reviews[['rating']]
labels = labels['rating'].apply(lambda l: 1 if l > 2 else 0)

reviews.head(), labels.head()

Out[12]:

(0    Good Quality Dog Food I have bought several of...
 1    Not as Advertised Product arrived labeled as J...
 2    "Delight" says it all This is a confection tha...
 3    Cough Medicine If you are looking for the secr...
 4    Great taffy Great taffy at a great price.  The...
 dtype: object,
 0    1
 1    0
 2    1
 3    0
 4    1
 Name: rating, dtype: int64)

Ich habe mich dafür entschieden, dass Reviews mit einem Rating zwischen 3 und 5 „positiv“ sind, ein Rating zw. 1 und 2 dagegen „negativ“. Darüber kann man sich nun streiten – besonders auch, ob man für Reviews mit Rating 3 nicht eine Kategorie „neutral“ einführen kann. Alles valide Einwände, für unsere Demonstration aber unerheblich 🙂

Viel wichtiger an dieser Stelle ist der Umstand, dass wir es nicht mehr länger mit einem Mini-Corpus und einem Vokabular aus 4 Fantasiebegriffen zu tun haben, sondern mit tausenden Reviews in natürlicher Sprache (Englisch)!

Der letzte Schritt unseres Preprocessings soll also daraus bestehen, dass wir ein bisschen NLP-Magic über unsere Dokumente träufeln. Dazu holen wir uns ein wenig Hilfe von spacy, einem großartigen NLP-Framework für Python:

In [13]:

import spacy

def lemmatize(doc: str, lemmatizer):
    return ' '.join((t.lemma_.lower() for t in nlp(doc) if t.is_alpha))

def preprocess_documents(docs: pd.Series):
    nlp = spacy.load("en_core_web_sm")
    lemmatizer = nlp.get_pipe("lemmatizer")
    
    return [lemmatize(text, nlp) for text in docs]

Das „heavy lifting“ wird übernommen von 𝚕𝚎𝚖𝚖𝚊𝚝𝚒𝚣𝚎()lemmatize(), wo, wer hätte es gedacht, ein Text genommen, von allen „Nicht-Wörtern“ (Zahlen, Satz- und Sonderzeichen etc.) befreit und anschließend jedes Wort auf seine Grundform (linguistisch „Lemma“) reduziert wird.

Hintergrund ist der, dass wir davon ausgehen, dass grammatikalische Aspekte (Einzahl / Mehrzahl, Zeitformen, Aspekt etc.) keine Rolle für das Sentiment eines Dokuments spielt und wir deshalb die Menge an Wörtern in unseren Bags of Words merklich reduzieren können.

Ein Beispiel dazu: Gramamtikalisch unterscheiden sich die Sätze „The girl is walking the dogs“ und „The girl was walking the dog“ in Zeit (present, past) und Numerus (dog – dogs), d.h. unser bag of words würde jeweils einen Eintrag für „is“, „was“ und „dog“ und „dogs“ enthalten – obwohl diese Unterschiede, wie gesagt, aller Wahrscheinlichkeit nach wenig informativ für das Sentiment eines Dokuments sind.

Lemmatization „entfernt“ diese Aspekte:

In [14]:

sentences = [
    "The girl is walking the dogs",
    "The girl was walking the dog"
]

nlp = spacy.load("en_core_web_sm")
lemmatizer = nlp.get_pipe("lemmatizer")

for sentence in sentences:
    print(sentence, "->", lemmatize(sentence, nlp))
The girl is walking the dogs -> the girl be walk the dog
The girl was walking the dog -> the girl be walk the dog

Beide Sätze werden nach dem Preprocessing auf dasselbe Dokument abgebildet – Profit!

Schritt 2: Vectorization und TF-IDF-Scores

Und damit sind wir auch schon im zweiten Schritt, der „Vektorisierung“, angelagt. Theoretisch könnten wir dazu unsere eigene tf-idf-Funktion aus dem ersten Teil verwenden, da der zweite Teil aber unter dem Motto „das Rad nicht neu erfinden“ steht, holen wir uns wieder ein wenig Hilfe, dieses Mal nicht bei spacy, sondern dem nicht weniger großartigen (und vermutlich sogar wesentlich verbreiteteren) scikit-learn-Framework:

In [15]:

from nltk.corpus import stopwords
from sklearn.feature_extraction.text import TfidfVectorizer

𝚃𝚏𝚒𝚍𝚏𝚅𝚎𝚌𝚝𝚘𝚛𝚒𝚣𝚎𝚛TfidfVectorizer übernimmt hier den gesamten Prozess der Vektorisierung für uns – wir liefern lediglich die (lemmatisierten) Dokumente als Input. Was der Vectorizer auch macht, ist einige zusätzliche Pre- und Postprocessing-Steps, in unserem Fall bspw. „stopword removal“ (= Entfernen von „inhaltsleeren“ Begriffen wie Personalpronomen, Konjunktionen etc.), Eindampfen der Bag of Words auf maximal 5.000 Begriffe sowie eine gewisse Normalisierung der tf-idf-Scores (L1-Norm, bei Interesse nachzulesen hier):

In [16]:

vectorizer = TfidfVectorizer(stop_words=stopwords.words('english'), max_features=5_000, norm='l1')

lemmatized_documents = preprocess_documents(reviews)
features = vectorizer.fit_transform(lemmatized_documents)
features

Out[16]:

<10000x5000 sparse matrix of type '<class 'numpy.float64'>'
	with 300749 stored elements in Compressed Sparse Row format>

Wie wir sehen können, besteht unsere „Dokumentenmatrix“ erwartungsgemäß aus 10.000 Dokumenten („Reihen“) mit 5.000 Termen („Spalten“). Diese können wir im nächsten Schritt dazu verwenden, ein Classifikation-Model auf unsere Sentiment Analysis-Task zu trainieren.

Schritt 3: Train and Predict

Wieder bedienen wir uns dafür scikit-learn:

In [17]:

from sklearn.svm import SVC
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix, accuracy_score

Für unser Modell habe ich mich für eine Support Vector Machine als Classifier entschieden, aus keinem anderen Grund außer dem, dass ich für gewöhnlich bei ML-Tasks immer erst Support Vector Machines ausprobiere, sofern andere Algorithmen nicht offensichtlich besser geeignet sind.

Außerdem habe ich gleich auch Funktionen zum Aufteilen unserer Matrix und Labels in Trainings- und Test-Sets, sowie zum Auswerten der Ergebnisse unserer Klassifizierung.

Als Test-Set werden wir 1.000 gelabelte Dokumente zurückhalten und unser Modell auf 9.000 Dokumenten trainieren:

In [18]:

classifier = SVC()

xtrain, xtest, ytrain, ytest = train_test_split(features, labels, test_size=0.1, random_state=0)
classifier.fit(xtrain, ytrain)
predictions = classifier.predict(xtest)

Schauen wir uns zuerst direkt an, wie hoch die accuracy unseres Modells auf „ungesehenen“ Daten ist:

In [19]:

accuracy_score(ytest, predictions)

Out[19]:

0.899

89,9% – das ist ziemlich gut! Vielleicht zu gut, denn wie bei fast allen Machine Learning-Projekten besteht die Gefahr, dass wir es hier mit Overfitting zu tun haben.

Werfen wir deshalb ebenfalls einen Blick auf die confusion matrix:

In [20]:

pd.DataFrame(confusion_matrix(ytest, predictions), index=['negative (actual)', 'positive (actual)'], columns=['negative (predicted)', 'positive (predicted)'])

Out[20]:

negative (predicted)positive (predicted)
negative (actual)57100
positive (actual)1842

Die confusion matrix zeichnet ein leicht anderes Bild: Von den 1.000 Testdokumenten waren 843 positiv, die unser Modell auch bis auf eines korrekt als solche erkannt hat. Von den übrigen 157 Dokumenten, die negativ waren, hat unser Modell aber lediglich 57 korrekt als negativ erkannt, 100 negative Dokumente aber als positiv klassifiziert!

Das heißt, unser Modell ist zwar gut darin, Reviews mit positivem Sentiment als solche zu erkennen, hat aber beim Erkennen von negativen Reviews deutlichen Nachholbedarf!

Summary

Damit sind wir am Ende unseres kleinen Abenteuers mit TF-IDF und Sentiment Analysis angelangt.

Im ersten Teil haben wir gelernt, dass TF-IDF ein Verfahren ist, mit dem wichtige Begriffe in einem Dokument erkannt werden können und Dokumente anhand dieser Begriffe dann in Kategorien eingeordnet werden können. Daher das auch geschehen kann, ohne dass im Vorfeld feste Kategorien festgelegt wurden, eignet sich tf-idf auch hervorragend zur Exploration von unbekannten Datensätzen.

Sentiment Analysis ist ein Spezialfall von „Zuordnung von Dokumenten zu Kategorien“, bei dem die Kategorien bereits feststehen („positiv“ und „negativ“) und ein ML-Modell auf vorgelabelten Dokumenten trainiert wird, um später ungelabelte Dokumente diesen beiden Kategorien zuordnen zu können.

Diesen Prozess haben wir im zweiten Teil des Artikels beleuchtet, indem wir eine Support Vector Machine als Classifier trainiert haben, das Produktreviews auf Amazon.com als „positiv“ oder „negativ“ bewerten kann. Das ging halbwegs gut, wobei das Modell einen deutlichen Bias gegenüber positiven Reviews hatte.

Abschließend hoffe ich, dass ich meinen Lesern das Thema „TF-IDF“ näher bringen konnte. Sollten trotzdem noch Fragen offen sein, Sie das Bedürfnis haben, Lob oder Kritk zum Artikel äußern zu wollen oder allgemein Interesse an NLP haben, dann zögern Sie bitte nicht, auf die Kollegen bei its-people zuzukommen – wir freuen uns!

Bildnachweise: © pixabay_quantum-physics-4550602_1920

Das könnte Sie auch interessieren

Bleiben Sie informiert:

its-people hilft Ihnen...

Weitere Blogthemen: