Natural Language Processing — Einsteigen und loslegen!

Keine Kommentare

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

  1. eine Sammlung von Reden deutscher Politiker einlesen, die Adrien Barbaresi1 zusammengestellt hat,
  2. einfache Schritte der Sprachanalyse auf diese Reden anwenden,
  3. statistische Informationen über das jeweils verwendete Vokabular extrahieren,
  4. einen Machine-Learning-Klassifikator darauf trainieren, die Reden dem jeweiligen Politiker zuzuordnen, der sie gehalten hat;
  5. die Ergebnisse auswerten und visualisieren.

Mehr dazu — ein Einführungsvideo, ein Jupyter-Notebook als Tutorial und Übungsaufgaben mit Lösungen — bietet das codecentric.AI-bootcamp!

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

import os
import numpy as np
import pandas as pd
import urllib
import zipfile
import xmltodict

DATA_PATH = "data"
DATA_FILE = "speeches.json"
REMOTE_PATH = "http://adrien.barbaresi.eu/corpora/speeches/"
REMOTE_FILE = "German-political-speeches-2018-release.zip"
REMOTE_URL = REMOTE_PATH + REMOTE_FILE
REMOTE_DATASET = "Bundesregierung.xml"

zip_path = os.path.join(DATA_PATH, REMOTE_FILE)
urllib.request.urlretrieve(REMOTE_URL, zip_path)
with zipfile.ZipFile(zip_path) as file:
    file.extract(REMOTE_DATASET, path=DATA_PATH)
xml_path = os.path.join(DATA_PATH, REMOTE_DATASET) 
with open(xml_path, mode="rb") as file:
    xml_document = xmltodict.parse(file)
    text_nodes = xml_document['collection']['text']
    df = pd.DataFrame({'person' : [t['@person'] for t in text_nodes],
                        '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:

import seaborn as sns
import matplotlib.pyplot as plt
%matplotlib inline
%config InlineBackend.figure_format = 'svg' # schönere Grafiken
sns.set()

df["length"] = df.speech.str.len()
sns.countplot(y="person", data=df).set(title="Anzahl an Reden", xlabel='', ylabel='')
sns.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.

Diagramm zur Anzahl der Reden pro Politiker

Auch die Längen der Reden variieren stark.

Diagramm zur Länge der Reden pro Politiker

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.‘:

df = df.groupby("person") \
        .apply(lambda g: g.sample(0 if len(g) < 50 else 50)) \
        .reset_index(drop=True)
df = 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.

  1. 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","."
  2. Stemming bestimmt zu jedem Wort den Wortstamm oder die Grundform und Lemmatisierung beschreibt, wie das Wort aus seiner Grundform durch Beugung gebildet wurde.
  3. 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.
  4. 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.

pip install spacy
python spacy download de_core_news_sm

Anschließend analysieren wir den Beispielsatz mit Hilfe von spaCy:

import spacy
import pandas as pd

nlp = spacy.load("de_core_news_sm")
document = nlp("Peter fährt auf seinem Fahrrad und lacht.")
pd.DataFrame({"Token": [word.text for word in document],
              "Grundform": [word.lemma_ for word in document],
              "Wortart": [word.pos_ for word in document]})

Als Ausgabe erhalten wir die folgende Tabelle:

TokenGrundformWortart
0PeterPeterPROPN
1fährtfahrenVERB
2aufaufADP
3seinemmeinDET
4FahrradFahrradNOUN
5undundCONJ
6lachtlachenVERB
7..PUNCT

Einen Syntax-Graphen kann spaCy ebenfalls anzeigen:

Syntax-Graph

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:

def analyze(speech):
    with nlp.disable_pipes("tagger", "parser"):
        document = nlp(speech)
        token = [w.text for w in document]
        lemma = [w.lemma_ for w in document]
        return (token, lemma)

df["analysis"] = df.speech.map(analyze)
df["tokens"] = df.analysis.apply(lambda x: x[0])
df["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,

  1. welche Wörter in einer Rede auftauchen — die Menge dieser Wörter wird auch bag of words genannt,
  2. wie oft diese Wörter jeweils auftauchen,
  3. die relative Häufigkeit
  4. 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.

def bow(speeches):
    word_sets = [set(speech) for speech in speeches]
    vocabulary = list(set.union(*word_sets))
    set2bow = lambda s: [1 if w in s else 0 for w in vocabulary]
    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:

speeches = [['am', 'Anfang', 'war', 'das', 'Wort'],
            ['und', 'das', 'Wort', 'war', 'bei', 'Gott'],
            ['und', 'Gott', 'war', 'das', 'Wort']
            ]
vocabulary, speeches_bow =  bow(speeches)
pd.DataFrame([vocabulary] + speeches_bow, index=['vocabulary'] + speeches)

Als Ausgabe erhalten wir folgende Tabelle:

01234567
vocabularydasWortwarundamGottAnfangbei
[am, Anfang, war, das, Wort]11101010
[und, das, Wort, war, bei, Gott]11110101
[und, Gott, war, das, Wort]11110100

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:

def bigrams(speech):
      return list(zip(speech[:-1], speech[1:]))

list(map(bigramify, speeches))

Als Ergebnis erhalten wir folgende Listen:

[[('am', 'Anfang'), ('Anfang', 'war'), ('war', 'das'), ('das', 'Wort')], 
 [('und', 'das'), ('das', 'Wort'), ('Wort', 'war'), ('war', 'bei'), ('bei', 'Gott')],
 [('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

  1. in dieser Rede oft auftaucht,
  2. 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:

import spacy
from spacy import displacy

nlp = spacy.load('de_core_news_sm')
text = """Donald wusste noch nicht, dass er am Montag in Entenhausen 0.3141 Taler an Dagobert zurückzuzahlen hatte."""
doc = nlp(text)
svg = 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:

from sklearn.preprocessing import LabelEncoder
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, confusion_matrix
import seaborn as sns

def train_test_evaluate(speeches, persons):
    # Durchnummerieren der Redner
    encoder = LabelEncoder()
    y = encoder.fit_transform(persons)
    # Bag of Words der Reden extrahieren
    vectorizer = CountVectorizer(binary=True)
    X = vectorizer.fit_transform(speeches).toarray()
    # Daten aufteilen für Training und Test
    X_train, X_test, y_train, y_test = train_test_split(X,y, test_size=0.3)
    # Klassifikator trainieren und testen
    classifier = MultinomialNB()
    classifier.fit(X_train, y_train)
    y_pred = classifier.predict(X_test)
    # Vorhersage-Genauigkeit auswerten
    print(accuracy_score(y_test, y_pred))
    sns.heatmap(confusion_matrix(y_test, y_pred),
                xticklabels=encoder.classes_,
                yticklabels=encoder.classes_)

Wenn wir nun die Reden wie anfangs in einen pandas-DataFrame df einlesen und die Funktion mit

train_test_evaluate(df['speech'], df['person'])

aufrufen, kommt bei uns eine Genauigkeit von etwa 75 Prozent und folgende Konfusions-Matrix heraus:

Konfusions-Matrix

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!

Thomas Timmermann

Thomas hat in Mathematik promoviert, Erfahrung in der Forschung und Weiterbildung gesammelt und verstärkt seit Oktober 2018 das Team in Münster im Bereich Data Science/Machine Learning. Besonders interessieren ihn alle Themen rund um KI und Deep Learning.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.