Natural Language Processing — Einsteigen und Loslegen!
2019-03-24
In diesem Post, den ich vor 2 Wochen auch auf dem codecentric-Blog veröffentlicht habe, werden wir
- eine Sammlung von Reden deutscher Politiker einlesen,
- auf diese Reden einfache Schritte der Sprachanalyse anwenden,
- zu dem jeweils verwendeten Vokabular statistische Informationen extrahieren,
- einen Bayes-Klassifikator trainieren, um die Reden dem jeweiligen Politiker zuzuordnen, der sie gehalten hat.
Mehr dazu — ein Einführungsvideo, ein Jupyter-Notebook als Tutorial und Übungsaufgaben mit Lösungen — bietet das codecentric.AI-bootcamp!
Der 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 ¶
Wir benutzen folgende Sammlung von Reden deutscher Politiker:
- 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.
Diese Reden sind 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 nodes = xml_document['collection']['text']
23 df = pd.DataFrame({'person' : [t['@person'] for t in nodes],
24 'speech' : [t['rohtext'] for t in 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). \
9 set(title="Anzahl an Reden", xlabel='', ylabel='')
10sns.boxplot(y="person", x="length", data=df). \
11 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.']
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
(Subst.),fährt
(Verb),auf
(Präp.),seinem
(Pronom),Fahrrad
(Subst.),und
(Konjunkt.),lacht
(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,
welches 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 |
---|---|---|
Peter | Peter | PROPN |
fährt | fahren | VERB |
auf | auf | ADP |
seinem | mein | DET |
Fahrrad | Fahrrad | NOUN |
und | und | CONJ |
lacht | lachen | VERB |
. | . | 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])
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. 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)))
Wir 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,
7 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)
$$ \mathrm{tf}(w,R) = \frac{\#(w,R)}{max_v \#(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)
$$ \mathrm{idf}(w) = \frac{\log N}{N(w)} $$
erfasst, wobei $N$ die Anzahl aller Reden bezeichnet und $N(w)$ die Anzahl der Reden, die $w$ enthalten. Das Produkt
$$ \mathrm{tf\cdot idf}(w,R) = \mathrm{tf}(w,R) \cdot \mathrm{idf}(w) $$
wird das tf-idf-Maß von w bezüglich R genannt. Dieses Maß und 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
6in Entenhausen 0.3141 Taler an Dagobert zurückzuzahlen hatte."""
7
8doc = nlp(text)
9svg = displacy.render(doc, style='ent', jupyter=True)
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 zunumerisierten 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,
17 test_size=0.3)
18 # Klassifikator trainieren und testen
19 classifier = MultinomialNB()
20 classifier.fit(X_train, y_train)
21 y_pred = classifier.predict(X_test)
22 # Vorhersage-Genauigkeit auswerten
23 print(accuracy_score(y_test, y_pred))
24 sns.heatmap(confusion_matrix(y_test, y_pred),
25 xticklabels=encoder.classes_,
26 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!