1 Worum geht es?
Ob Suchmaschinen, Spamfilter, Chatbots oder Sprachassistenten wie Siri und Alexa — Computer verarbeiten immer mehr Sprache mit immer besserer Genauigkeit und dringen damit immer weiter in unseren Alltag vor.
Dahinter stecken anspruchsvolle statistische Methoden, stochastische Modelle und neuronale Netze aus den Labors von Tech-Giganten und Universitäten. Aber dank zahlreicher Open-Source-Bibliotheken sind diese Techniken für jeden zugänglich und anwendbar. Vorausgesetzt, man bringt ein wenig Grundwissen mit.
Dieser Blog-Post gibt eine Einführung in die Grundlagen von Natural Language Processing, kurz NLP, und zeigt als praktische Anwendung, wie man mit wenig Aufwand und Standard-Python-Bibliotheken Texte klassifizieren kann. Genauer werden wir im Folgenden
- eine Sammlung von Reden deutscher Politiker einlesen, die Adrien Barbaresi1 zusammengestellt hat,
- einfache Schritte der Sprachanalyse auf diese Reden anwenden,
- statistische Informationen über das jeweils verwendete Vokabular extrahieren,
- einen Machine-Learning-Klassifikator darauf trainieren, die Reden dem jeweiligen Politiker zuzuordnen, der sie gehalten hat;
- die Ergebnisse auswerten und visualisieren.
1 Barbaresi, Adrien (2018). A corpus of German political speeches from the 21st century. Proceedings of the Eleventh International Conference on Language Resources and Evaluation (LREC 2018), European Language Resources Association (ELRA), pp. 792–797.
2 Unser Datensatz: Politiker-Reden
Der Ausgansgpunkt für NLP ist roher Text, der aus den unterschiedlichsten Quellen stammen kann, zum Beispiel
- gesprochene Sprache, die über speech recognition (SR) in Text umgewandelt wird,
- gescannte Dokumente, die mittels optical character recognition (OCR) bearbeitet werden,
- Emails und Social-Media-Beiträge,
- Informationen aus dem Web, die per web crawling oder web scraping gesammelt werden.
Datenbeschaffung
In unserem Fall sind die Texte, also die Reden, in einer XML-Datei als Teil eines Zip-Archivs enthalten. Im Folgenden laden wir diese Zip-Datei aus dem Web herunter, extrahieren die Reden aus der XML-Datei und geben den Datensatz in einem pandas-DataFrame zurück. Letzteres ist eine Tabelle mit einer Zeile pro Rede und zwei Spalten, welche den jeweiligen Redner beziehungsweise den Rohtext enthalten
1import os 2import numpy as np 3import pandas as pd 4import urllib 5import zipfile 6import xmltodict 7 8DATA_PATH = "data" 9DATA_FILE = "speeches.json" 10REMOTE_PATH = "http://adrien.barbaresi.eu/corpora/speeches/" 11REMOTE_FILE = "German-political-speeches-2018-release.zip" 12REMOTE_URL = REMOTE_PATH + REMOTE_FILE 13REMOTE_DATASET = "Bundesregierung.xml" 14 15zip_path = os.path.join(DATA_PATH, REMOTE_FILE) 16urllib.request.urlretrieve(REMOTE_URL, zip_path) 17with zipfile.ZipFile(zip_path) as file: 18 file.extract(REMOTE_DATASET, path=DATA_PATH) 19xml_path = os.path.join(DATA_PATH, REMOTE_DATASET) 20with open(xml_path, mode="rb") as file: 21 xml_document = xmltodict.parse(file) 22 text_nodes = xml_document['collection']['text'] 23 df = pd.DataFrame({'person' : [t['@person'] for t in text_nodes], 24 'speech' : [t['rohtext'] for t in text_nodes]})
Daten sichten und bereinigen
Werfen wir mit seaborn einen Blick auf die Anzahl und Länge der Reden, sortiert nach den Politikern:
1import seaborn as sns 2import matplotlib.pyplot as plt 3%matplotlib inline 4%config InlineBackend.figure_format = 'svg' # schönere Grafiken 5sns.set() 6 7df["length"] = df.speech.str.len() 8sns.countplot(y="person", data=df).set(title="Anzahl an Reden", xlabel='', ylabel='') 9sns.boxplot(y="person", x="length", data=df).set(title="Länge der Reden in Zeichen", xlabel="", ylabel="")
Wir sehen, dass der Datensatz sehr unausgewogen ist, der Name Julian Nida-Rümelin mindestens einmal falsch geschrieben wurde und für einige Reden kein Redner angegeben ist.
Auch die Längen der Reden variieren stark.
Um unsere Aufgabe zu vereinfachen und die Verarbeitung zu beschleunigen, wählen wir zufällig pro Politiker 50 Reden aus und schließen Politiker mit weniger Reden aus. Außerdem entfernen wir Reden mit der Angabe ‚k.A.‘:
1df = df.groupby("person") \ 2 .apply(lambda g: g.sample(0 if len(g) < 50 else 50)) \ 3 .reset_index(drop=True) 4df = df[df['person'] != 'k.A.']
3 Sprachanalyse
Der rohe Text liegt meist als Zeichenkette, also als Folge von Buchstaben, Ziffern, Satzzeichen und so weiter vor. Natürliche Sprache besteht aber aus Sätzen mit einer bestimmten grammatikalischen Struktur und Wörtern, die entsprechend gebeugt werden.
Der Weg vom Rohtext zu dieser Struktur geht über folgende Einzelschritte.
- Tokenisierung zerlegt den Rohtext in eine Folge von Wörtern, Satzzeichen, Zahlen, Abkürzungen und sonstigen Artefakten wie Email- oder Webadressen. Aus der Zeichenkette
"Peter fährt auf seinem Fahrrad und lacht."
wird dann beispielsweise die Folge
"Peter"
,"fährt"
,"auf"
,"seinem"
,"Fahrrad"
,"und"
,"lacht"
,"."
- Stemming bestimmt zu jedem Wort den Wortstamm oder die Grundform und Lemmatisierung beschreibt, wie das Wort aus seiner Grundform durch Beugung gebildet wurde.
- Part-of-speech-Tagging, kurz POS-Tagging, bestimmt zu jedem Wort die Wortart, also, ob es sich um ein Substantiv, Verb, Pronom, Präposition et cetera handelt. Als Ergebnis erhalten wir zum Beispiel
"Peter"
,"fährt"
,"auf"
,"seinem"
,"Fahrrad"
,"und"
,"lacht"
,"."
Subst., Verb, Präp., Pronom, Subst., Konjunkt., Verb, Interpunkt. - Parsing schließlich bestimmt die grammatikalische Struktur eines Satzes.
Nicht alle Schritte sind für jede Anwendung nötig oder nützlich.
Anwendung mit spaCy
Wie Stemming, POS-Tagging und Parsing funktionieren, kann und soll an dieser Stelle nicht erklärt werden. Die Anwendung ist aber dank einiger NLP-Bibliotheken in Python ganz einfach. Wir benutzen im Folgenden spaCy („industrial strength natural language processing“). Andere geeignete Bibliotheken wären beispielsweise NLTK („the natural language toolkit“) und gensim („topic modelling for humans“).
Zuerst installieren wir per Kommandozeile die Bibliothek spaCy und laden ein sogenanntes Sprachmodell, das statistische Informationen über die Sprache der Texte enthält, die spaCy analysieren soll.Hier verwenden wir de_core_news_sm
, das auf Artikeln der Wikipedia und Artikeln der Frankfurter Rundschau basiert.
1pip install spacy 2python spacy download de_core_news_sm
Anschließend analysieren wir den Beispielsatz mit Hilfe von spaCy:
1import spacy
2import pandas as pd
3
4nlp = spacy.load("de_core_news_sm")
5document = nlp("Peter fährt auf seinem Fahrrad und lacht.")
6pd.DataFrame({"Token": [word.text for word in document],
7 "Grundform": [word.lemma_ for word in document],
8 "Wortart": [word.pos_ for word in document]})
Als Ausgabe erhalten wir die folgende Tabelle:
Token | Grundform | Wortart | |
---|---|---|---|
0 | Peter | Peter | PROPN |
1 | fährt | fahren | VERB |
2 | auf | auf | ADP |
3 | seinem | mein | DET |
4 | Fahrrad | Fahrrad | NOUN |
5 | und | und | CONJ |
6 | lacht | lachen | VERB |
7 | . | . | PUNCT |
Einen Syntax-Graphen kann spaCy ebenfalls anzeigen:
Anwendung auf die Politiker-Reden
Wir tokenisieren und lemmatisieren die Politiker-Rede und tragen die erhaltenen Listen der Token beziehungsweise Grundformen jeweils in eine neue Spalte tokens
beziehungsweise lemmata
von unserem pandas-DataFrame df
ein. Um die Analyse zu beschleunigen, schalten wir das POS-Tagging und Parsing ab:
1def analyze(speech): 2 with nlp.disable_pipes("tagger", "parser"): 3 document = nlp(speech) 4 token = [w.text for w in document] 5 lemma = [w.lemma_ for w in document] 6 return (token, lemma) 7 8df["analysis"] = df.speech.map(analyze) 9df["tokens"] = df.analysis.apply(lambda x: x[0]) 10df["lemmata"] = df.analysis.apply(lambda x: x[1])
4 Von Token-Folgen zu Features
Nach der NLP-Vorverarbeitung müssen wir aus den Reden sogenannte Features extrahieren, also kategorielle oder numerische Größen, anhand derer Machine-Learning-Algorithmen oder neuronale Netze die Reden klassifizieren können. Dafür eignen sich statistische Informationen über die Token beziehungsweise Wörter, zum Beispiel,
- welche Wörter in einer Rede auftauchen — die Menge dieser Wörter wird auch bag of words genannt,
- wie oft diese Wörter jeweils auftauchen,
- die relative Häufigkeit
- oder kompliziertere Statistiken wie das tf-idf-Maß — mehr dazu gleich.
Diese Informationen werden dann für jede Rede in einem Vektor zusammengefasst. Genauer wird
- zuerst das Gesamt-Vokabular aller Reden bestimmt und durchnummeriert,
- anschließend für jede Rede ein Vektor gebildet, dessen i-te Komponente die jeweilige Statistik für das i-te Token des Gesamt-Vokabulars bezüglich der Rede enthält.
Zum Beispiel setzt man im Fall der 1. Statistik — bag of words — die i-te Kompontente des Vektors einer Rede auf 0 oder 1, je nachdem, ob die Rede das i-te Token des Gesamt-Vokabulars enthält oder nicht.
Bag of words per Hand
Am Einfachsten lassen sich diese Statistiken mit Hilfe von Bibliotheken wie scikit-learn ermitteln. Aber es ist aufschlussreich und auch nicht schwer, so etwas einmal selbst zu programmieren. Die folgende Funktion erwartet eine Sammlung von Token-Folgen und gibt das Gesamt-Vokabular als Liste (und damit implizit durchnummeriert) sowie die jeweiligen Vektoren als Listen zurück.
1def bow(speeches): 2 word_sets = [set(speech) for speech in speeches] 3 vocabulary = list(set.union(*word_sets)) 4 set2bow = lambda s: [1 if w in s else 0 for w in vocabulary] 5 return (vocabulary, list(map(set2bow, word_sets)))
Wie wenden wir die Funktion auf ein paar Beispielsätze an und verwenden pandas, um das Ergebnis tabellarisch darzustellen:
1speeches = [['am', 'Anfang', 'war', 'das', 'Wort'], 2 ['und', 'das', 'Wort', 'war', 'bei', 'Gott'], 3 ['und', 'Gott', 'war', 'das', 'Wort'] 4 ] 5vocabulary, speeches_bow = bow(speeches) 6pd.DataFrame([vocabulary] + speeches_bow, index=['vocabulary'] + speeches)
Als Ausgabe erhalten wir folgende Tabelle:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
---|---|---|---|---|---|---|---|---|
vocabulary | das | Wort | war | und | am | Gott | Anfang | bei |
[am, Anfang, war, das, Wort] | 1 | 1 | 1 | 0 | 1 | 0 | 1 | 0 |
[und, das, Wort, war, bei, Gott] | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 1 |
[und, Gott, war, das, Wort] | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 0 |
Unser einfaches Vorgehen lässt sich auf verschiedene Arten variieren und verbessern. Zum Beispiel kann man statt der Token einer Rede die jeweiligen Wortstämme verwenden oder einige der folgenden Techniken anwenden.
Stopp-Wörter
Viele Wörter wie zum Beispiel „Artikel“ tauchen in fast jeder Rede auf und sind deswegen für die Klassifikation kaum hilfreich. Solche Wörter werden Stopp-Wörter genannt. Am Besten filtert man sie einfach aus den Reden heraus. Die NLP-Bibliothek NLTK stellt beispielsweise eine Liste mit 231 Stopp-Wörtern bereit, die wie folgt anfängt:
aber
,alle
,allem
,allen
,aller
,alles
,als
,also
,am
,an
,ander
,andere
,anderem
,anderen
,anderer
,anderes
,anderm
,andern
,anderr
,anders
,auch
,auf
,aus
,bei
,bin
,bis
,bist
,da
,damit
,dann
,das
,dasselbe
,dazu
,daß
,dein
,deine
,deinem
,deinen
,deiner
,deines
,dem
,demselben
,den
,denn
,denselben
,der
,derer
,derselbe
,derselben
,des
, …
Aber Vorsicht: filtert man diese Stopp-Wörter heraus, so kann dadurch der Sinn von Sätzen verdreht werden. Aus
Grönlandhaie können nicht fliegen
wird dann beispielsweise, weil können
und nicht
in der NLTK-Stopp-Wort-Liste enthalten sind,
Grönlandhaie fliegen
N-Gramme
Manche Wortpaare oder längere Wortgruppen ergeben einen Sinn, der sich nicht aus den Einzelwörtern erschließt, wie beispielsweise New York
oder Papa Schlumpf
. Deswegen kann es sinnvoll sein, statt einzelner Wörter auch alle Wortpaare oder allgemeiner alle Gruppen von N aufeinanderfolgenden Wörtern — sogenannte N-Gramme — und deren Statistiken zu betrachten. In Python können wir zum Beispiel wie folgt Bigramme extrahieren:
1def bigrams(speech): 2 return list(zip(speech[:-1], speech[1:])) 3 4list(map(bigramify, speeches))
Als Ergebnis erhalten wir folgende Listen:
1[[('am', 'Anfang'), ('Anfang', 'war'), ('war', 'das'), ('das', 'Wort')], 2 [('und', 'das'), ('das', 'Wort'), ('Wort', 'war'), ('war', 'bei'), ('bei', 'Gott')], 3 [('und', 'Gott'), ('Gott', 'war'), ('war', 'das'), ('das', 'Wort')]]
Das tf-idf-Maß
Stopp-Wörter sind für die Klassifikation oft nicht nützlich, weil sie in den meisten Reden oft vorkommen. Besonders charakteristisch für eine Rede — und damit hilfreich für die Klassifikation — ist ein Wort, wenn es
- in dieser Rede oft auftaucht,
- aber insgesamt in nur wenigen anderen Reden erscheint.
Bezeichne #(w,R) die Anzahl, wie oft ein Wort w in einer Rede R auftaucht. Ein gutes Maß für die Eigenschaft 1 ist die relative Vorkommenshäufigkeit (term frequency)
tf(w,R) = #(w,R) / maxv #(v,R),
die dank der Normierung stets zwischen 0 und 1 liegt. Die Eigenschaft 2 wird durch das inverse Dokumentenhäufigkeits-Maß (inverse document frequency)
idf(w) = log N/Nw
erfasst, wobei N die Anzahl aller Reden bezeichnet und Nw die Anzahl der Reden, die w enthalten. Das Produkt
tfidf(w,R) = tf(w,R) ⋅ idf(w)
wird das tf-idf-Maß von w bezüglich R genannt. Dieses Maß wird besonders oft für die Klassifikation von Texten verwendet.
Named entities recognition (NER)
Texte enthalten oft Namen von Personen, Orts- oder Zeitangaben und andere Bezeichnungen, die für die Klassifikation oder für andere Anwendungen relevant sind. Die Erkennung und Extraktion solcher Angaben wird als named entity recognition bezeichnet und von den bereits genannten NLP-Bibliotheken in unterschiedlichem Umfang angeboten. Beispielsweise kann spaCy in dem deutschen Text
Donald wusste noch nicht, dass er am Montag in Entenhausen 0,3141 Taler an Dagobert zurückzuzahlen hatte.
die Personennamen und Ortsangabe erkennen und anzeigen,
Donald PER wusste noch nicht, dass er am Montag in Entenhausen LOC 0,3141 Taler an Dagobert PER zurückzuzahlen hatte.
in der englischen Übersetzung auch Zahlen und Zeitangaben (und vieles mehr):
Donald PERSON did not know yet that on Monday DATE , he’d have to pay back Dagobert PERSON 0.3141 dollars MONEY in Duckville GPE .
Das funktioniert wie folgt:
1import spacy 2from spacy import displacy 3 4nlp = spacy.load('de_core_news_sm') 5text = """Donald wusste noch nicht, dass er am Montag in Entenhausen 0.3141 Taler an Dagobert zurückzuzahlen hatte.""" 6doc = nlp(text) 7svg = displacy.render(doc, style='ent', jupyter=True)
5 Klassifikation mit scikit-learn
Genug der Theorie — wie funktioniert die Klassifikation praktisch? Mit scikit-learn ist das mit wenigen Zeilen erledigt:
- ein LabelEncoder nummeriert die Redner durch,
- ein CountVectorizer erzeugt für jede Rede ihre bag of words,
- die Funktion train_test_split zerlegt die numerisierten Daten in Trainings- und Testdaten,
- ein MultinomialNB wendet Bayessche Klassifikation an,
- die Funktionen accuracy_score und confusion_matrix berechnen schließlich die Genauigkeit und eine Konfusions-Matrix der Vorhersage,
- die Visualisierungsbibliothek seaborn zeigt schließlich die Konfusions-Matrix als heatmap an.
1from sklearn.preprocessing import LabelEncoder 2from sklearn.feature_extraction.text import CountVectorizer 3from sklearn.naive_bayes import MultinomialNB 4from sklearn.model_selection import train_test_split 5from sklearn.metrics import accuracy_score, confusion_matrix 6import seaborn as sns 7 8def train_test_evaluate(speeches, persons): 9 # Durchnummerieren der Redner 10 encoder = LabelEncoder() 11 y = encoder.fit_transform(persons) 12 # Bag of Words der Reden extrahieren 13 vectorizer = CountVectorizer(binary=True) 14 X = vectorizer.fit_transform(speeches).toarray() 15 # Daten aufteilen für Training und Test 16 X_train, X_test, y_train, y_test = train_test_split(X,y, test_size=0.3) 17 # Klassifikator trainieren und testen 18 classifier = MultinomialNB() 19 classifier.fit(X_train, y_train) 20 y_pred = classifier.predict(X_test) 21 # Vorhersage-Genauigkeit auswerten 22 print(accuracy_score(y_test, y_pred)) 23 sns.heatmap(confusion_matrix(y_test, y_pred), 24 xticklabels=encoder.classes_, 25 yticklabels=encoder.classes_)
Wenn wir nun die Reden wie anfangs in einen pandas-DataFrame df
einlesen und die Funktion mit
1train_test_evaluate(df['speech'], df['person'])
aufrufen, kommt bei uns eine Genauigkeit von etwa 75 Prozent und folgende Konfusions-Matrix heraus:
Dies zeigt uns, dass die Fehler bei der Klassifikation hauptsächlich bei Reden von Christina Weiss aufgetreten sind — diese wurden von unserem Klassifikator recht oft Bernd Neumann beziehungsweise Michael Naumann zugeschrieben. Vielleicht kann hier das tf-idf-Maß helfen? Oder neuronale Netze?
Mehr dazu und viele weitere spannende Themen rund um Machine Learning und Deep Learning bietet das codecentric.AI-Bootcamp!
Weitere Beiträge
von Thomas Timmermann
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Thomas Timmermann
Data Scientist
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.