Nachdem wir in unserem letzten Blogbeitrag (hier) gezeigt haben, wie man mit Web Scraping eine Sammlung von Texten aufbauen kann, beschäftigen wir uns in diesem zweiten Teil der Blogserie zu “Natural Language Processing mit Python” mit der Weiterverarbeitung der Daten um sie für verschiedene Use Cases aufzubereiten.
Warum überhaupt Text vorverarbeiten?
Liest man einen Text so können wir als Menschen sehr schnell den Inhalt, Sinn und auch den Kontext eines Textes erfassen. Warum muss man also einen Text erst einmal aufbereiten und warum ist das überhaupt interessant? Man muss bedenken, dass das, was für uns einzelne Wörter, Sätze, Smilies, etc. sind, für den Computer zunächst nichts anderes ist als eine Ansammlung von einzelnen Zeichen. Man spricht in diesem Zusammenhang auch oft von “unstrukturierten Daten”. Zwar weist ein Text typischerweise eine gewisse Struktur auf, diese ist allerdings linguistischer Natur, und damit für Menschen ausgelegt und nicht für Maschinen.
Zum einen liegt der Sinn der Vorverarbeitung also darin, diese unstrukturierte Sammlung von Zeichen in ein System zu übersetzen, ihr eine Struktur zu geben (z.B. in einzelne Untereinheiten aufzuteilen), die es ermöglicht den Text automatisiert auf verschiedene Eigenschaften hin auszuwerten. Das kann z.B. sein, welches generelle Thema der Text hat (Text classification), zu beurteilen welche Texte sich ähnlich sind (Clustering), oder aber z.B. welche Stimmung im Text ausgedrückt wird (Sentiment Analysis) usw.
Zum anderen beinhalten Textdaten wegen Ihres Ursprungs oft eine große Menge an unterschiedlichsten Inhalten, die aber nicht alle von Interesse für einen Use Case sind. Das können je nach Use Case ganz unterschiedliche Inhalte sein, wie z.B. Satzzeichen, HTML-Tags, Smilies oder Zahlen. Darüber hinaus enthalten Texte oft zusätzliche Herausforderungen, wie z.B. grammatikalische und orthografische Fehler, Umgangssprache, Abkürzungen usw. Deshalb liegt ein zweites Ziel der Vorverarbeitung von Texten darin den Text von unerwünschten Informationen zu befreien bzw. diese durch sinnvolle Informationen zu ersetzen.
Textvorverarbeitung mit Python
Für Python existiert eine große Auswahl an Libraries für Natural Language Processing, die sich bezüglich ihres Funktionsumfanges und ihrer Performance z.T. deutlich unterscheiden. Die folgenden Links zeigen eine Auswahl der gängigsten Libraries:
Welches nun die beste Library ist hängt vom konkreten Anwendungsfall ab. Als Ausgangspunkt für die Auswahl können z.B. diese Papers hier, hier oder auch hier dienen.
Für unsere Zwecke haben wir uns aufgrund des Funktionsumfangs und der Performance für die Nutzung von spaCy entschieden. Bei spaCy handelt es sich um eine Open-Source Software Bibliothek, die in Python und Cython geschrieben ist. Die hohe Performance von spaCy kommt daher, dass der Cython Quellcode in optimierten C/C++ Code übersetzt und zu Python-Erweiterungsmodulen kompiliert wird. Im Vergleich zu Libraries wie NLTK, die zwar z.T. einen größeren Funktionsumfang (im Sinne von mehr Auswahl an Algorithmen) bieten, ist spaCy für den produktiven Einsatz ausgelegt.
Der zweite große Vorteil von spaCy ist, dass es eine Reihe von Sprachmodellen für verschiedene Sprachen (Deutsch, Englisch, Spanisch, Portugiesisch, Französisch, Italienisch, Niederländisch) enthält siehe hier. Warum Sprachmodelle etwas Gutes sind, werden wir später noch genauer sehen. Nur so viel sei vorweggenommen: Jede Sprache hat ihre eigenen Regeln und Ausnahmen. Je besser diese sprachspezifischen Regeln mit in die Verarbeitung einbezogen werden, desto besser kann z.B. ein Algorithmus erkennen ob es sich bei einem Token um eine sinnvolle Einheit handelt.
Im folgenden wollen wir nun beispielhaft typische Vorverarbeitungsschritte einer NLP Pipeline mit spaCy darstellen.
Libraries und Settings
Zuerst laden wir die nötigen Libraries:
github:55d4b338c5e70adcd0e1f614ac1715e9
Um alle Funktionen von spaCy für deutsche Sprache nutzen zu können, müssen wir zunächst die notwendigen Sprachdateien für Deutsch herunterladen:
github:5116ce95c220428b49a69e023f80e48a
… und definieren eine kleine Hilfsfunktion zur besseren Darstellung der Tokens:
github:f09dcca830a128c6279417ce40c5719a
Beispiel-Texte
Viele der Schritte und Konzepte sind leichter an einem einfachen Beispiel zu verstehen. Deshalb definieren wir zunächst eine Menge von vier Beispieldokumenten. Die Inhalte sind dabei nicht wirklich von Bedeutung sondern die Dokumente sind eher darauf ausgelegt, den Umgang mit Spezialfällen, wie Abkürzungen, Punktuationen, Zahlen und verschieden ähnlichen Dokumenten zu verdeutlichen.
github:d84399fe7c787c3d59d028ac0779b040
Tokenisierung
Wie wir auch in unseren Beispieldokumenten sehen, liegen Texte typischerweise in der Form eines einzelnen, langen, zusammenhängenden Strings vor. Um Informationen aus diesem String zu extrahieren, muss dieser String zunächst in seine Einzelbestandteile zerlegt werden. Diesen Schritt nennt man Tokenisierung.
Das hört sich zunächst einmal ganz einfach an: Ein naiver erster Versuch wäre das Dokument einfach dort zu splitten wo ein Leerzeichen ist, da Wörter in unserer Sprache typischerweise durch Leerzeichen voneinander getrennt sind.
github:f77d64dee940c8cc6135877c7e55d45d
Wir sehen, dass dieses simple Vorgehen prinizpiell gar nicht so schlecht funktioniert. Zusammengehörige Buchstaben wie "z.B." bleiben eine Einheit. Problematisch ist dieses Vorgehen aber z.B. für Token Nummer 12. Wir sehen, dass sowohl die Klammer als auch der Punkt zum Token hinzugefügt werden. Dies ist insofern problematisch, da z.B. die Wörter "Uhr", "Uhr.", und "Uhr)." als unterschiedliche Tokens erkannt werden würden.
Eine andere Möglichkeit wäre z.B. den String an allen Stellen zu teilen, die kein Wortzeichen (also alphanumerische Zeichen bzw. auch Umlaute, ß, etc.) sind.
github:f4fadb62af25d8df905e6d5ca2b6e980
Dies behebt das Problem der Klammern und Satzzeichen. Allerdings werden dabei zusammengehörige Abkürzungen wie "z.B." zerteilt.
Wie wir sehen handelt es sich bei der Tokenisierung um eine doch nicht ganz triviale Aufgabe, und man muss sich für den jeweiligen Anwedungsfall genau überlegen, wie die Tokenisierung am besten geschehen soll.
Tokenisierung mit spaCy
Der große Vorteil von spaCy ist, dass es über ein Sprachmodell, u.a. auch der deutschen Sprache, verfügt. In einem solchen Sprachmodell kann man Ausnahmen und Spezialfälle für die Tokenisierung festlegen. So macht es z.B. Sinn Wörter wie "auf'm" in zwei Tokens "auf" und "'m" zu splitten, die man im Anschluss weiterverarbeiten kann.
Damit dieses Modell genutzt wird, muss es zunächst geladen werden. Dies geschieht über die Funktion spacy.load, die wir für das deutsche Sprachmodell mit dem Argument ‘de’ aufrufen.
Sobald man das Modell geladen hat, kann man damit das Dokument analysieren, dies passiert mit Hilfe der Funktion nlp. In seiner Grundeinstellung führt spaCy nicht nur eine Tokenisierung, sondern auch andere Schritte durch (wie part-of-speech tagging, dependency parsing, und named entity recognition), diese alle zu beschreiben würde aber den Rahmen dieses Beitrags sprengen.
github:f3395a7d6e93d651cd612bfb07a1f564
Wie wir sehen, sieht die Tokenisierung unter Einbezug eines Sprachmodells schon besser aus:
- "z.B." bleibt als feststehende Abkürzung zusammengehörig.
- "auf'm" wird in zwei Bestandteile aufgeteilt.
- Alle Satzzeichen und Klammern werden als eigenständiger Token erkannt.
Diese gute Performance liegt daran, dass der Algorithmus zur Tokenisierung in spaCy deutlicher ausgefeilter ist als die beiden naiven Vorgehensweisen, die wir vorher examplarisch gezeigt haben. Wie der Tokenisierungsalgorithmus von spaCy funktioniert kannst Du in der Abbildung unten sehen:
Zunächst verfolgt spaCy erst einmal den gleichen Ansatz wie wir und splittet den Satz nach Leerzeichen. Soweit, so gut. Dann werden aber iterativ die einzelnen Tokens von links nach rechts abgearbeitet und zunächst Präfixe und Suffixe abgetrennt (dies ist z.B. für Anführungszeichen, Klammern, Satzzeichen, usw. sinnvoll). Dann wird geprüft ob es sich beim Token um eine Ausnahme handelt (ein Beispiel wäre hier wieder unser Wort "auf'm", das in zwei Tokens gesplitted werden sollte).
Auf diese Art und Weise ist es mit spaCy möglich auch komplexe, verschachtelte Texte zu tokenisieren. Sollte dieser Algorithmus den eigenen Anforderungen noch nicht genügen, bietet spaCy übrigens ebenso die Möglichkeit den Standard-Tokenizer anzupassen und zu erweitern (siehe hier).
Lemmatisierung
Nun haben wir es durch Tokenisierung geschafft ein Dokument in sinnvolle Einzelteile aufzusplitten. Allerdings würden so im Text noch verschiedene Einzelteile als unterschiedliche Tokens koexistieren, obwohl sie sich inhaltlich entsprechen. Ein einfaches Beispiel wäre das Wort "ist": So könnte es als das Wort "war", "sind", "seid", usw. in verschiedenen Formen im Text vorkommen (Singular vs. Plural, verschiedene Zeiten, Steigerungen, usw.), jedoch grundsätzlich die gleiche Bedeutung haben. Deshalb würde man beim NLP versuchen diese einzelnen Tokens auf ihre “Grundform” also ihr “Lemma” (siehe hier) zurückzuführen.
Wiederum ist dieser Schritt nicht ganz trivial und setzt zum einen Informationen aus einem Sprachmodell, sowie eine Klassifizierung des Tokens voraus. Es ist z.B. wichtig ob ein Token ein Verb oder Substantiv ist, um es auf die richtige Grundform zurückführen zu können. So sollte z.B. das Wort "fragen" entweder auf "fragen" lemmatisiert werden wenn es sich um ein Verb handelt, jedoch auf das Wort "frage" wenn es sich um ein Substantiv handelt.
Dabei kann ein sogenannter Part-of-speech (POS) Tagger helfen, der ebenfalls in spaCy implementiert ist.
Nach der Analyse mit spaCy sieht unser lemmatisiertes Dokument sieht nun so aus:
github:56176fe56c953dc475448de8db8ebdb3
Datenbereinigung
Nach der Lemmatisierung haben wir unsere Dokumente schon in einer Form, in der man sie gut weiterverarbeiten kann. Allerdings können je nach Use Case noch andere Schritte zur Bereinigung der Daten notwendig sein. So können z.B. Satzzeichen oder Zahlen irrelevant sein und man kann diese je nach Bedarf über verschiedene reguläre Ausdrücke filtern.
Punktuation
github:532b613f034adc5b6c658805b3ae94a8
Zahlen
github:26cae2b6c9762b28d3927baaa893bee0
Normalisierung
Eine weitere Möglichkeit den Text zu bereinigen besteht darin ihn zu normalisieren, d.h. von Groß- und Kleinschreibung abzusehen. Dazu werden typischerweise alle Tokens in Kleinbuchstaben umgewandelt. Dies einerseits sinnvoll um Wörter unterschiedlicher Schreibweise zusammenzufassen, wie z.B. "Iphone", "iphone", oder "IPHONE", andererseits kann es sich auch auf gewisse Analysen nachteilig auswirken, falls man für eine Anwendung z.B. eine Unterscheidung zwischen Substantiven und Verben benötigt.
github:ab33969de741174efa6ddad559850086
Entfernung von Stoppwörtern
Sogenannte Stoppwörter sind Wörter, die sehr häufig in einer Sprache vorkommen und daher für gewöhnlich keine Relevanz in Bezug auf den Inhalt bzw. die Bedeutung eines Dokuments besitzen. Allgemein übliche Stoppwörter sind bestimmte und unbestimmte Artikel (der, die, das; ein, einer, eine), Konjunktionen (z.B. und, oder, doch) und Präpositionen (z.B. an, in, von).
Zur Entfernung dieser Wörter gibt es unterschiedliche Möglichkeiten:
- Bei Stoppwortlisten erstellt man manuell eine Liste von irrelevanten Wörtern. Wörter im Dokument die auf dieser Liste zu finden sind werden entfernt.
- Eine andere Möglichkeit besteht darin die Häufigkeit der einzelnen Wörter über mehrere Dokumente hinweg zu bestimmen und dann automatisiert Wörter die sehr häufig vorkommen zu entfernen.
- Eine ausgefeiltere Möglichkeit besteht darin, die Relevanz von einzelnen Wörtern zu bestimmen und im Anschluss irrelevante Wörter zu entfernen. Ein gängiges Maß dafür, wie relevant ein Wort für ein Dokument ist, ist z.B. die sogenannte Term Frequency Inverse Document Frequency.
Die Grundidee dabei ist: Ein Wort ist besonders dann kennzeichnend für den Inhalt eines Dokuments, wenn es zwar im untersuchten Dokument häufig vorkommt, in anderen Dokumenten aber sehr selten ist.
Ein einfaches Beispiel: Das Wort ‘der’ kommt in vielen Dokumenten vor. Es ist also für das einzelne Dokument inhaltlich kein besonders relevantes Wort. Wenn ein Dokument aber häufig das Wort ‘Lemmatisierung’ benutzt, ist das ein sehr relevantes Wort, da es in anderen Dokumenten eher sehr selten vorkommt.
Um Stoppwörter zu entfernen verfolgt spaCy den Ansatz von Stoppwortlisten und stellt für mehrere Sprachen solche Listen zur Verfügung. Zum Beispiel für Deutsch:
github:4f76c2bb65efa7676893a2415c082047
Nach der Entfernung von Stoppwörtern:
github:6927ab87a1e91064a0b56ffd12826565
Datenbereinigung mit spaCy
Die gezeigten Schritte zur Datenbereinigung lassen sich komfortabel mit spaCy umsetzen. spaCy verfolgt grundsätzlich die Strategie keine Informationen aus dem Dokument zu entfernen, sondern stattdessen die einzelnen Tokens mit Meta-Informationen anzureichern bzw. zu taggen, die man im Anschluss nutzen kann um Tokens zu filtern.
D.h. nach der Tokenisierung schickt spaCy die Tokens durch eine Pipeline, bei der z.B. die Ergebnisse von parts-of-speech tagging, dependency parsing (wo die grammatikalischen Beziehungen zwischen den Wörtern dargestellt werden können), etc. zu den Tokens hinzugefügt werden. Für eine Liste an Tokenattributen siehe hier.
So enthält am Ende der Pipeline jedes Token zusätzlich Attribute darüber, ob es sich um ein Stoppwort handelt (is_stop), eine Zahl (is_digit), ein Satzzeichen (is_punct), oder aber auch wie eine Emailadresse aussieht (like_email) und vieles mehr.
Diese Attribute können wir benutzen um alle unerwünschten Tokens zu filtern (z.B. alle Zahlen, Satzzeichen und Stoppwörter).
github:b3e065fcf10d9f1da7ef95a249c6dcc6
Eigene Erweiterungen für spaCy
Die oben genannten Attribute werden in der Pipeline allerdings aus dem ursprünglichen Text generiert. Deshalb wurde z.B. das Wort "der" nicht gefiltert, da es sich um die lemmatisierte Form von "'m" des Worts "auf'm" handelt. Aber auch für solche Ausnahmen gibt es die Möglichkeit spaCy anzupassen: So hat man die Möglichkeit eigene Attribute zu setzen und eine eigenen Funktion zu übergeben, die während der spaCy Pipeline aufgerufen wird.
Für unseren Fall definieren wir also eine Funktion stop_words_getter, die nicht nur die ursprünglichen Tokens, sondern auch deren Lemma (in normaler und kleingeschriebener Form) dahingehend überprüft ob es in unserer Stoppwortliste vorkommt.
Im Anschluss können wir mit der Methode Token.set_extension ein neues Attribut mit dem Namen is_stop_custom definieren und unsere Funktion als Argument übergeben. Auf Attribute die man über dieses Vorgehen definiert, kann man dann im Anschluss über token._.Attributname zugreifen. Unsere eigenes Attribut ist dann über token._.is_stop_custom anzusprechen, das Standard-Attribut über token.is_stop.
github:b26642e1a682aa9ebcdc8b71fb2436a2
Betrachtet man den Unterschied zwischen der 3. und 4. Zeile, sieht man bei Token 5 "'m", dass es nun zusätzlich aufgrund seines Lemmas als Stoppwort identifiziert wurde.
github:3de7006e52070e9e399fa5f7e2c51f72
Zusammenfassung
Wir haben in diesem Artikel eine exemplarische Vorverarbeitungs-Pipeline für Texte mit spaCy gezeigt. Dabei haben wir gesehen, dass es bei den einzelnen Verarbeitungsschritten wichtig ist sich für den jeweiligen Use Case genau zu überlegen, wie der einzelne Schritt durchgeführt werden soll, z.B. wie genau tokenisiert werden soll.
Im nächsten Teil unserer Blogserie zeigen wir Dir wie man mehrere Dokumente so repräsentieren kann, dass sie für tiefergehende Analysen und Use Cases wie Text classification, Sentiment Analysis, etc. geeignet sind.
Falls Du auch Lust hast Dich mit Themen wie NLP, Data science, Data pipelines, Cloud computing, oder Python zu beschäftigen schau doch einfach mal auf unsere Jobs (s. Button unten). Wir freuen uns darauf Dich kennenzulernen.
Bis zum nächsten Mal