Sentimentanalyse¶
Ausgangslage¶
Die Physik beansprucht gerne für sich, eine vollkommen objektive und neutrale Wissenschaft zu sein. Die US-amerikanische Kulturanthropologin Sharon Traweek spricht auch von einer „culture of no culture“[1]. Doch wenn Schüler_innen über Physik sprechen, dann klingt das häufig alles andere als neutral.
Findet sich ein solcher Trend auch in meinen Daten, insbesondere wenn ich die Antworten von SmartMatters4You Teilnehmerinnen mit Antworten von anderen Schüler_innen vergleiche? Schnell bin ich auf die Methode der Sentimentanalyse gestoßen. Bei dieser Methode der natürlichen Sprachverarbeitung geht es darum, die Stimmung eines Textes zu erkennen. Mit modernen Computeralgorithmen aus dem Machine Learning wird diese Methode aus der Linguistik zu einem leistungsstarken Tool, das Unternehmen dabei hilft, automatisiert die Kundenzufriedenheit aus Rezensionen abzuleiten.
Ich komme dagegen mit einem sehr viel einfacheren Modell aus, weil ich in meiner Erhebung nur nach drei Eigenschaften frage. In meinem Fragebogen heißt es:
Mit welchen drei Eigenschaften würdest du eine_n typische_n Physiker_in beschreiben, bzw.
Mit welchen drei Eigenschaften würdest du eine_n typische_n Chemiker_in beschreiben?
Es folgt ein Freitextfeld. Erwartungsgemäß enthält mein Datensatz überwiegend Adjektive. Ich habe die Wörter händisch bzgl. Rechtschreibung korrigiert und dann eine Häufigkeitsanalyse gemacht. Dabei zeigt sich, dass gewisse Eigenschaften dominieren:
| Physiker_in: | Chemiker_in |
|---|---|
| 'schlau': 101 Mal | 'schlau': 86 Mal |
| 'intelligent': 43 Mal | 'intelligent': 42 Mal |
| 'interessiert': 30 Mal | 'neugierig': 36 Mal |
| 'mathematisch': 29 Mal | 'interessiert': 28 Mal |
| 'neugierig': 18 Mal | 'experimentierfreudig': 27 Mal |
| 'logisch': 18 Mal | 'verrückt': 16 Mal |
| 'klug': 16 Mal | 'wissbegierig': 13 Mal |
| 'nerd': 12 Mal | 'motiviert': 10 Mal |
| 'verrückt': 11 Mal | 'klug': 9 Mal |
| 'wissbegierig': 10 Mal | 'genau': 8 Mal |
Tatsächlich stellten mich Antworten, die keine Adjektive sind, vor Herausforderungen (z.B. Taperfade, Drogen, Atombombe,...) Das sind zwar eindeutig Assoziationen, die von einem gewissen Stereotyp zeugen, sie beantworten aber nicht die Frage. Da ich nach Eigenschaften fragte, entschied ich mich dazu, die wenigen Wörter zu auszusortieren, die keine Eigenschaften widergeben. Außerdem formulierte ich Antworten um, die sich in Adjektive umwandeln ließen (z.B. begabt in Mathematik -> mathebegabt). Da ich meinen Algorithmus zugunsten einer hohen Datenqualität nur mit Adjektiven trainieren würde, fürchtete ich, dass Nomen und insbesondere Strings mit mehr als zwei Wörtern die Analyse mehr verfälschen würden, als unechte Komposita ('mathebegabt').
Sentimentwörterbücher¶
Ein lexikalischer Ansatz war für mich unbrauchbar, denn viele Begriffe waren nicht im Wörterbuch zu finden. Was liegt also näher, als ein bestehendes Wörterbuch in einem hybriden Ansatz mit maschinellem Lernen zu verbinden?
Im englischsprachigen Bereich gibt es zahlreiche Bibliotheken, wie z.B. das Natural Language Toolkit (NLTK) für Python, das ganze Texte im Zusammenhang analysieren kann. Und auch bei MAXQDA sollen smarte Sentimentfunktionen integriert sein. Im deutsprachigen Bereich ist dagegen deutlich weniger zu finden.
Das bekannte SentiWS von der Universität Leizig führt etwa 1600 positive und 1800 negativ gewertete Begriffe auf. Mich irritiert es mit einer irreführenden Genauigkeit von vier(!) Nachkommastellen auf einer Skala von -1 (negativ) bis +1 (positiv). Darüber hinaus enthält es Informationen zu Flexionsvarianten, die für mich aber irrelevant sind. Was sich für mich dagegen sehr nützlich erweisen könnte, sind Infos zu Wortarten.
Auffällig im SentiWS ist die Tendenz zur Mitte, aber das möchte ich nicht kritisieren, da ich mich nicht mit den Algorithmen nicht näher beschäftigt habe, die zusammenhängende Texte im Kontext erfassen. Solche Probleme lassen sich nachträglich durch Kalibrierung lösen. In der Praxis findet sich das obere Ende der Skala bei etwa 0.7 (Lob, perfekt, spannend, wunderbar, wunderschön). Nach unten wird die Skala mit bis zu -0.9 dagegen etwas mehr ausgereizt (Schuld, schwach, schädlich, unnötig,... um nur einige zu nennen). Trotzdem blieben alle Versuche ein sinnvolles Modell mit dem SentiWS aufzubauen erfolglos.
Bei der Interest Group on German Sentiment Analysis (IGGSA) fand ich schon bald ein sehr viel besseres Wörterbuch, das PolArt[2]. Mit rund 4000 Nomen, fast ebenso vielen Adjektiven und etwa 1500 Verben bei diskreten Werten auf einer Skala {−1,−0.7,−0.5,0,0.5,0.7,1} schien sich das PolArt-Wörterbuch gut zu eignen. Insbesondere die Randwerte wurden sehr viel besser ausgeschöpft. Damit war das grundsätzliche Problem von SentiWS behoben und das Training konnte starten.
Word2Vec¶
Mit einem Word2Vec-Ansatz werden Wörter in einen dreihundertdimensionalen Vektorraum repräsentiert. Wörter mit semantischer Ähnlichkeit, liegen räumlich dichter beieinander.
Genau dieser Fasttextbefehl gibt den Vektor eines Wortes aus: ft.get_word_vector('Beispielwort')
Und der Kosinus-Ähnlichkeitskoeffizient eignet sich übrigens als Maß für die Ähnlichkeit zweier Wörter (das habe ich in einem vorherigen Ansatz gebraucht).
Deep Learning Modell¶
Daten einlesen¶
Code
import numpy as np import pandas as pddf = pd.read_csv('polartlexicon.txt', sep=r'\s+', header=None, names=['text', 'label', 'type'])
print("\nRohdaten:") print(df)
df['text'] = df['text'].astype(str)
df[['direction', 'strength']] = df['label'].str.split('=', expand=True) del df['label']
df['strength'] = df['strength'].astype(float)
print("\nAufbereitete Daten mit Richtung:") print(df)
mapping = { 'POS': lambda x: x, 'NEG': lambda x: -x, 'NEU': lambda x: 0 }
df['sentiment'] = df.apply(lambda row: mapping.get(row['direction'], lambda x: float('nan'))(row['strength']), axis=1)
df = df.dropna(subset=['sentiment'])
print("\nNochmal aufbereitete Daten mit gelöschten NAs:") print(df.head(20))
counts = df['type'].value_counts() print('\nZählung:') print(counts)
df = subset_df = df[(df['type'] == 'adj') | (df['type'] == 'verben')]
del df['direction'] del df['strength']
print("\nGefiltert auf Adjektive, unnötige Spalten rausgeworfen:") print('\n') print(df.head(20))
counts = df['type'].value_counts() print('\nZählung:') print(counts)
df.to_csv('/home/julian/Schreibtisch/Python/Word2Vec/Fasttext/polart.csv', index=False, header=None)
Rohdaten:
text label type
0 fehlschlagen NEG=0.7 verben
1 vermindern INT=0.5 verben
2 aufhören SHI=0 verben
3 beenden SHI=0 verben
4 beliebt POS=0.7 adj
... ... ... ...
9425 Zitat NEU=1 nomen
9426 Zuhause NEU=1 nomen
9427 Zusammenleben NEU=1 nomen
9428 Zuschauer NEU=1 nomen
9429 Zwerg NEU=1 nomen
[9430 rows x 3 columns]
Aufbereitete Daten mit Richtung:
text type direction strength
0 fehlschlagen verben NEG 0.7
1 vermindern verben INT 0.5
2 aufhören verben SHI 0.0
3 beenden verben SHI 0.0
4 beliebt adj POS 0.7
... ... ... ... ...
9425 Zitat nomen NEU 1.0
9426 Zuhause nomen NEU 1.0
9427 Zusammenleben nomen NEU 1.0
9428 Zuschauer nomen NEU 1.0
9429 Zwerg nomen NEU 1.0
[9430 rows x 4 columns]
Nochmal aufbereitete Daten mit gelöschten NAs:
text type direction strength sentiment
0 fehlschlagen verben NEG 0.7 -0.7
4 beliebt adj POS 0.7 0.7
5 gewöhnungsbedürftig adj NEG 0.5 -0.5
6 lecker adj POS 0.7 0.7
7 miserabel adj NEG 0.5 -0.5
8 säuerlich adj NEG 0.5 -0.5
9 spitze adj POS 1.0 1.0
10 spritzig adj POS 0.5 0.5
11 süffig adj POS 0.5 0.5
12 undefinierbar adj NEG 0.7 -0.7
13 wohlriechend adj POS 0.7 0.7
14 wohlschmeckend adj POS 0.7 0.7
15 würzig adj POS 0.5 0.5
21 Extremist nomen NEG 1.0 -1.0
22 Nullpunkt nomen NEG 0.7 -0.7
23 Plagiat nomen NEG 0.7 -0.7
24 fertigbringen verben POS 0.7 0.7
27 Ablehnung nomen NEG 0.7 -0.7
28 Scheitern nomen NEG 1.0 -1.0
29 scheitern verben NEG 1.0 -1.0
Zählung:
type
nomen 3975
adj 3841
verben 1561
Name: count, dtype: int64
Gefiltert auf Adjektive, unnötige Spalten rausgeworfen:
text type sentiment
0 fehlschlagen verben -0.7
4 beliebt adj 0.7
5 gewöhnungsbedürftig adj -0.5
6 lecker adj 0.7
7 miserabel adj -0.5
8 säuerlich adj -0.5
9 spitze adj 1.0
10 spritzig adj 0.5
11 süffig adj 0.5
12 undefinierbar adj -0.7
13 wohlriechend adj 0.7
14 wohlschmeckend adj 0.7
15 würzig adj 0.5
24 fertigbringen verben 0.7
29 scheitern verben -1.0
40 leider adj -0.7
42 stinklangweilig adj -1.0
43 streiten verben -1.0
45 ramponieren verben -1.0
50 beeindrucken verben 0.7
Zählung:
type
adj 3841
verben 1561
Name: count, dtype: int64
Training starten¶
Code
import fasttext import numpy as np import pandas as pd from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Input, Dropout from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping from tensorflow.keras import regularizersreg_strength = 0.001
ft = fasttext.load_model('cc.de.300.bin')
df = pd.read_csv('polart.csv', sep=',', header=None, names=['text', 'type', 'sentiment'])
print("\nRohdaten:") print(df)
df['sentiment'] = df['sentiment'].astype(float)
counts = df['type'].value_counts() print('\nZählung:') print(counts)
df['vector'] = None df['vector'] = df['text'].apply(ft.get_word_vector)
print('\nFertiger Dataframe:') print(df[0:4])
X_train = np.stack(df['vector'].values)
Y_train = df['sentiment'].values
print(f"Form von X_train (Features): {X_train.shape}") print(f"Form von Y_train (Labels): {Y_train.shape}")
model = Sequential([ Input(shape=(300,), name='input_layer'),
Dense(units=128, activation='relu'), Dropout(0.5), Dense(units=64, activation='relu'), Dropout(0.5),Dense(units=1, activation='tanh', name='output_layer')])
model.compile(optimizer=Adam(learning_rate=0.0001,clipvalue=1.0), loss='mean_squared_error', metrics=['mae'])
early_stopping = EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True )
print('\n') model.summary()
sample_weights_linear = 1 + np.abs(Y_train)
print("Starte Training...") history = model.fit( x=X_train, y=Y_train, sample_weight=sample_weights_linear, epochs=100,
batch_size=128,
validation_split=0.1, callbacks=[early_stopping], verbose=1 ) print("Training abgeschlossen.")import matplotlib.pyplot as plt
loss = history.history['loss'] val_loss = history.history['val_loss'] mae = history.history['mae'] val_mae = history.history['val_mae'] epochs = range(1, len(loss) + 1)
plt.figure(figsize=(14, 6))
plt.subplot(1, 2, 1) plt.plot(epochs, loss, 'bo-', label='Trainings-Loss') plt.plot(epochs, val_loss, 'r-', label='Validierungs-Loss') plt.title('Trainings- und Validierungs-Loss') plt.xlabel('Epochen') plt.ylabel('Loss (MSE)') plt.legend() plt.grid(True)
plt.subplot(1, 2, 2) plt.plot(epochs, mae, 'bo-', label='Trainings-MAE') plt.plot(epochs, val_mae, 'r-', label='Validierungs-MAE') plt.title('Trainings- und Validierungs-MAE') plt.xlabel('Epochen') plt.ylabel('MAE') plt.legend() plt.grid(True)
plt.show()
model.save('sentiment_adjektive_model_polart.keras')
Nach dem Optimieren der Parameter hatte ich eine optimale Architektur gefunden, die den Validierungs-MAE auf 0.35 drückt. Tatsächlich schienen Verben den semantischen Raum vorteilhaft zu erweitern, deshalb nahm ich sie mit hinzu.
Validierung¶
Die Metrik sieht vernünftig aus, aber jetzt wollen wir prüfen, ob das Modell auch inhaltlich sinnvolle Ergebnisse liefert.
Bei den (zufällig ausgewählten) Testwörtern zur Validierung habe ich darauf geachtet, dass die Wörter nicht im ursprünglichen Trainingsdatensatz enthalten sind.
Code
import numpy as np import fasttext from tensorflow.keras.models import load_modelmodel = load_model('/home/julian/Schreibtisch/Python/Word2Vec/Fasttext/sentiment_adjektive_model_polart.keras') ft = fasttext.load_model('/home/julian/Schreibtisch/Python/Word2Vec/Fasttext/cc.de.300.bin')
list = [ "himmlisch",
"phantastisch",
"beachtenswert",
"ausreichend",
"witzig",
"grün",
"gelb",
"verheerend",
"mürrisch",
]for adj in list: new_vector = ft[adj] new_vector = np.expand_dims(new_vector, axis=0) predicted_sentiment = model.predict(new_vector, verbose=0)[0][0] print(f"{adj}: {predicted_sentiment:.2f}")
himmlisch: 0.92
phantastisch: 0.79
beachtenswert: 0.70
ausreichend: 0.67
witzig: 0.83
grün: 0.48
gelb: -0.83
verheerend: -0.85
mürrisch: -0.94
Kritische Beurteilung des Modells¶
Insgesamt liefert das Modell Ergebnisse, die grundsätzlich in die richtige Richtung gehen und auch bis zum Maximum der Skala reichen. Bei einigen Wörter aber erlaubt sich das Modell kritische Fehltritte.
Für das Wort 'ausreichend' hätte ich z.B. einen deutlich gerigneren Wert erwartet, denn in Schulnoten ist das eine 4.0. Auch den sehr hohen Wert von 'witzig' habe ich nicht erwartet. Das Wort ist im Trainingsraum vermutlich mit Spaß und positiven Emotionen verknüpft. Und auch die Farben hätte ich deutlich neutraler eingeschätzt. Im Trainingsraum gibt es vermutlich eine Beziehung wie:
- rot/gelb -> Warnfarbe, Achtung, Gefahr!
- grün -> Farbe der Entspannung / 'alles im grünen Bereich'
Der Fairness halber muss man aber dazu sagen, dass das Modell auch nicht mit Farben trainiert wurde. Wenn es wichtig sein soll, Farben als neutral einzuschätzen, muss man entsprechende Daten dem Trainingssatz eben hinzufügen. Das Modell kann nur so gut sein, wie die Trainingsdaten.
Auswertung¶
Angewendet auf meinen Datensatz ergeben sich kaum negative Sentimentwerte. Die Adjektive sind nicht so eindeutig positiv oder negativ, wie im Trainingsdatensatz. Man kann subtile Töne in die ein oder andere Richtung hineininterprettieren, aber bei isolierten Wörtern ist das ein fragwürdiges Vorgehen. Hier kommen die quantitativen Forschungsmethoden an ihre Grenzen. Und Sentimentwerte hängen von der Lebenswelt und Kultur ab: In akademischen Kreisen hat Intelligenz wohl eher eine positive Bedeutung hat, aber im Umfeld von Schüler_innen kann das Wort auch negativ konnotiert sein. Denn es ist keineswegs sicher, dass die meistgenannten Wörter „schlau“ oder „intelligent“ ein Ausdruck von Bewunderung sind. Möglicherweise wird hier auch eine vermeintliche Genialität verspottet und verachtet, fernab der eigenen Lebensrealität. Ohne Kontext lässt sich diese Ambiguität wohl nicht auflösen. Dieser Exkurs war also ein spannender Ausflug, für die Einwortanalyse der Adjektive hat mich das aber nicht wirklich weiter gebracht.