Goehte, Hesse, Lessing, Schiller.

Wir klassifizieren Dichter: Data-Preprocessing

Namaste! Es geht mit großen Schritten weiter in Richtung Ziel. Wir erinnern uns ans gewagte Vorhaben: Ein Dichter-Klassifikator. Klingt ein Text eher nach Johann Wolfgang von Goethe, Gotthold Ephraim Lessing, Friedrich Schiller oder Hermann Hesse? Der Dichter-Klassifikator weiß es! Heute steht Data-Preprocessing auf dem Plan. Das ist der letzte Schritt vorm Training.

In meinem ersten Artikel ging es um den Download des Textkorpus‘. Ohne Daten lässt sich nur schwer Deep-Learning betreiben. Wir haben uns hier in der freien Bibliothek von Project Gutenberg bedient und uns ein paar Daten gesichert. Im zweiten Artikel wurde dieser Textkorpus dann analysiert. Die Texte wurden auf Herz und Nieren geprüft und die Resultate wurde grafisch dargestellt. Bevor wir endlich ein Neural-Network trainieren – darum geht es uns ja – müssen wir die Daten vorverarbeiten. Warum machen wir das?

Neural-Networks mögen vorverarbeitete Daten. Deswegen Data-Preprocessing.

Ja, es stimmt. Wir merken grad wieder, dass Deep-Learning einen hohen Data-Science-Anteil hat. Das sind die berühmten 80%. Zum Deep-Learning gehört auch das Data-Encoding. Denn Neural-Networks sind ein wenig anspruchsvoll, wenn es um die zu lernenden Daten geht…

Idealerweise füttert man die Neural-Networks mit Fließkommazahlen. Andere Kodierungen funktionieren eher schwer bis gar nicht. Zum Glück lassen sich die üblichen Daten durch Zahlenreihen darstellen. Das nennt man klassisch „Vektorisierung“. Bilder, Sounds, Texte, Messdaten und so weiter sehen herrlich als Vektoren aus. Und die Transformation ist einfach. Wie das mit Wörtern geht, hatte ich ja schon in meinem Artikel über Word-Embeddings geschrieben. Aus Wörtern werden Vektoren. Mit Vektoren können wir rechnen.

Auch in diesem Artikel werden wir wieder mit Word-Embeddings arbeiten. Und auch hier werden wir spaCy benutzen. Diese API fürs Natural-Language-Processing liefert Wort-Vektoren einfach mit. Und die Vektoren können wir damit sogar für Sätze, Absätze, Kapitel und ganze Bücher berechnen. Wunderbar! Let us begin!

Wir legen los. In der Python-Welt wie gehabt mit vielen Imports.

Es ist bestimmt bekannt, dass Python von diesen vielen kleinen und brillanten Imports lebt. Es ist beinahe schon ein Running-Gag, dass man in Python recht viele Module importiert und diese dann nur zusammensteckt. Böse Zungen behaupten gar, Python-Programmieren sei kein echtes Programmieren. Ich sehe das aber anders.

Im Anfang steht der Import und ein paar wichtige, globale Daten:

import os
import glob
from sklearn.preprocessing import label_binarize
from sklearn.model_selection import train_test_split
import spacy
from nltk.corpus import stopwords
import string
import numpy as np
import pickle
import shutil

remove_preprocessed_data = True

print("Loading spacy-model...")
spacy_german = spacy.load('de')
german_stopwords = stopwords.words("german")
punctuations = string.punctuation

Wir nehmen alles mit was wir brauchen mit. Wir initialisieren das deutsche spaCy-Modell für später. Und wir besorgen uns die Stopwords und Satzzeichen. Auch für einen späteren Schritt im Data-Preprocessing.

Danach steigen wir voll ein. Ohne main, no gain:

def main(args=None):
    corpus_path = "corpus"
    preprocessed_data_path = "preprocessed"
    preprocess_corpus(corpus_path, preprocessed_data_path)

Die Methode preprocess_corpus macht genau das, wonach sie klingt. Sie macht die Vorverarbeitung für den ganzen Textkorpus im angegebenen Ordner. Doch halt! Wie sahen die Trainingsdaten nochmal aus?

Alle Dateien, ihre Klassen und die Trainingsdaten.

Die Trainingsdaten für jeden Klassifikator sind Input-Output-Paare. Ein Neural-Network wird solange auf Inputs und Outputs trainiert, bis es ein der Lage ist, zu verallgemeinern. Wenn das Network gut trainiert ist, kann es selbstständig korrekte Outputs generieren.

In unserem Falle sind die Inputs einzelne Absätze und die Outputs sind die Dichternamen. Die Absätze kriegen wir aus den Text-Dateien. Die Dichternamen, also die Klassen, sind im Ordernamen abgelegt. Hier nochmal das passende Bild, das alles sehr deutlich macht: Der Korpus auf der Festplatte. Bereit fürs Data-Preprocessing.

Als Beispiel ein Auszug zum Drüber-Hinweg-Scrollen aus einem Goethe-Text. Damit klar ist was wir tun:

Egmont

Ein Trauerspiel in Fünf Aufzügen

Johann Wolfgang von Goethe

Personen

Margarete von Parma, Tochter Karls des Fünften, Regentin der Niederlande.
Graf Egmont, Prinz von Gaure.
Wilhelm von Oranien.
Herzog von Alba.
Ferdinand, sein natürlicher Sohn.
Machiavell, im Dienst der Regentin.
Richard, Egmonts Geheimschreiber.
Silva,)-unter Alba dienend.
Gomez,)-
Klärchen, Egmonts Geliebte.
Ihre Mutter.
Brackenburg, ein Bürgerssohn.
Soest, Krämer,    )-Bürger von Brüssel.
Jetter, Schneider,)-
Zimmermeister,    )-
Seifensieder,     )-
Buyck, Soldat unter Egmont.
Ruysum, Invalide und taub.
Vansen, ein Schreiber.
Volk, Gefolge, Wachen u. s. w.


Der Schauplatz ist in Brüssel.



ERSTER AUFZUG.


Armbrustschießen.

Soldaten und Bürger mit Armbrüsten.
Jetter, Bürger von Brüssel, Schneider, tritt vor und spannt die Armbrust.
Soest, Bürger von Brüssel, Krämer.

So weit, so gut. So sehen also die Rohdaten aus. Hier ist der Code der Methode, die das große Datenverarbeiten für den ganzen Textkorpus macht – preprocess_corpus:

def preprocess_corpus(corpus_path="corpus", preprocessed_data_path="preprocessed"):

    # Make sure that there is a proper folder for preprocessing.
    if os.path.exists(preprocessed_data_path) and remove_preprocessed_data is True:
        shutil.rmtree(preprocessed_data_path)
    if not os.path.exists(preprocessed_data_path):
        os.makedirs(preprocessed_data_path)

    # Grab the class-names from the filesystem together with the encodings.
    class_names, one_hot_encodings = get_classes_with_encodings(corpus_path)
    for index, class_name in enumerate(class_names):
        print(class_name, "->", one_hot_encodings[index])

    # Derive the training data from the data-set.
    data_in, data_out = get_training_data(class_names, one_hot_encodings, corpus_path)

    # Split into training and text. Then print statistics.
    X_train, X_test, y_train, y_test = train_test_split(data_in, data_out, test_size=0.2, random_state=21)
    print('X_train size: {}'.format(len(X_train)))
    print('X_test size: {}'.format(len(X_test)))
    print('y_train size: {}'.format(len(y_train)))
    print('y_test size: {}'.format(len(y_test)))

    # Writing to file.
    preprocessed_data_name = "preprocessed.pickle"
    preprocessed_data_path = os.path.join(preprocessed_data_path, preprocessed_data_name)
    print("Writing preprocessed data to", preprocessed_data_path)
    preprocessed_data = X_train, X_test, y_train, y_test, class_names
    with open(preprocessed_data_path, "wb") as output_file:
        pickle.dump(preprocessed_data, output_file)

    print("Done.")

Als erstes werden die Klassen ermittelt. Diese werden für später als One-Hot-Encodings abgespeichert. Das macht die Methode get_classes_with_encodings. Dann werden mit den Encodings die gesamten Trainingsdaten generiert. Das mach wiederum get_training_data. Wie immer im Deep-Learning würfeln wir die Daten einmal durch (gut schütteln!) und splitten in Trainings-Daten und Test-Daten. Siehe train_test_split. Und wenn das durch ist, schreiben wir die Daten mit Pickle auf die Festplatte.

Das war im groben Überflug die große Kern-Methode. Was im Detail passiert schauen wir uns jetzt an. Puzzle-Stücke.

Nach dem Gesamtüberblick kommen nun die Details. Die kleinen Geheimnisse vom Data-Preproccessing.

Wir schauen uns die einzelnen Schritte der großen Methode mit der sprichwörtliche Lupe an. Als erstes ist die Prozedur get_classes_with_encodings interessant:

def get_classes_with_encodings(corpus_path):

    # Get all the classes from filesystem.
    class_names = [element for element in os.listdir(corpus_path) if not os.path.isfile(os.path.join(corpus_path, element))]
    print(class_names)

    # Compute one-hot-encodings.
    print("Computing one-hot-encodings for classes...")
    one_hot_encodings = label_binarize(class_names, classes=class_names)
    for index in range(len(class_names)):
        class_name = class_names[index]
        one_hot_encoding = one_hot_encodings[index]

    return class_names, one_hot_encodings

Diese Methode merkt sich die Namen der Unterordner, also der Dichter. Und dazu generiert sie die One-Hot-Encodings. Wie geht das? Das geschieht in diesen drei Zeilen von preprocess_corpus:

class_names, one_hot_encodings = get_classes_with_encodings(corpus_path)
for index, class_name in enumerate(class_names):
    print(class_name, "->", one_hot_encodings[index])

Im Terminal offenbart sich sofort das schöne Mapping:

Computing one-hot-encodings for classes...
Hesse, Hermann -> [1 0 0 0]
Goethe, Johann Wolfgang von -> [0 1 0 0]
Lessing, Gotthold Ephraim -> [0 0 1 0]
Schiller, Friedrich -> [0 0 0 1]

Vier Dichter, vier Klassen, jeweils vier Elemente im Array. Und die 1 steht an der Stelle des Index‘. Trivial!

Nach den Outputs kommen die Inputs. Wir kodieren die Texte.

Die Methode get_training_data verarbeitet unsere vier Klassen einzeln. Und zwar so:

def get_training_data(class_names, one_hot_encodings, corpus_path):

    # Process classes.
    data_in = []
    data_out = []
    for index, class_name in enumerate(class_names):
        print("Processing", class_names)
        one_hot_encoding = one_hot_encodings[index]

        # Process the files for the class and extend training set.
        ins, outs = process_class(corpus_path, class_name, one_hot_encoding)
        data_in.extend(ins)
        data_out.extend(outs)

    return data_in, data_out

Die rechte Seite der Trainingsdaten hatten wir schon. Die Outputs haben wir ja schon seit vorhin als One-Hot-Encodings dabei. Jetzt ist die linke Seite dran. Das sind die Inputs. Die Variable data_in enthält alle Inputs und die Variable data_out alle passenden Outputs. Das sind zwei große Arrays, die Klasse um Klasse, Text um Text gefüllt werden. Und das macht process_class:

def process_class(corpus_path, class_name, one_hot_encoding):

    ins = []
    outs = []

    # Get all files.
    glob_path = os.path.join(corpus_path, class_name, "*.body.txt")
    body_file_paths = glob.glob(glob_path)
    body_file_paths = body_file_paths[0:1]

    # For each file split data into paragraphs. Each paragraph is then turned into a document-vector.
    # After that we have all data as input-output.
    for body_file_path in body_file_paths:
        print("Processing file", body_file_path + "...")
        with open(body_file_path) as body_file:
            body = body_file.read()
            paragraphs = split_into_paragraphs(body)
            vectors = [doc.vector for doc in spacy_german.pipe(paragraphs, batch_size=500, n_threads=1)]
            vectors = np.array(vectors)
            ins.extend(vectors)
            for _ in range(len(vectors)):
                outs.append(one_hot_encoding)

            # Double checking.
            if len(ins) != len(outs):
                raise Exception("Inconsistency.", len(data_in), len(data_out))

    return ins, outs

Wieder recht einfach. Die Dateien ermitteln, die zu den Autor gehören. Pro Datei den Text laden und dann mit split_into_paragraphs in einzelne Absätze schneiden. Dazu kommen wir dann gleich. Zeile 18 füllt mit spaCy das Array vectors mit den Absatz-Vektoren. spaCy hat dafür eine Pipe-Methode, mit der man große Textmengen auf einmal verarbeiten kann. Das funktioniert gut! Und kurz vor Schluss, betrachten wir noch die Methode, die aus Texten die Paragrafen extrahiert:

def split_into_paragraphs(text):
    paragraphs_ret = []

    paragraphs = text.split("\n\n")
    for paragraph in paragraphs:
        text, tokens = clean_up_text(paragraph)
        if len(tokens) >= 3:
            paragraphs_ret.append(text)

    return paragraphs_ret

Das könnte man natürlich auch anders machen. Etwa in Sätze schneiden. Die Vektoren würden sich dann analog berechnen. Eine kleine Methode am Ende ist clean_up_text. Diese bügelt über beliebige Texte und bereinigt diese. Da werden die Stopwords und die Satzzeichen entfernt:

def clean_up_text(text):
    doc = spacy_german(text)

    tokens = [tok.lemma_.lower().strip() for tok in doc if tok.lemma_ != '-PRON-']
    tokens = [tok for tok in tokens if tok not in german_stopwords and tok not in punctuations]

    text = ' '.join(tokens)
    return text, tokens

Well done! Ja, das war’s schon 🙂

Zusammengefasst…

Wir haben wieder viel Data-Science erlebt. Dieses Mal haben wir nicht analysiert sondern amtlich verarbeitet. Wir haben den gesamten Korpus für das Neural-Network-Training aufbereitet. Wir habe die Inputs und die Outputs berechnet. Also Absatz-Vektoren und One-Hot-Encodings. Und wir haben sie in eine Datei geschrieben. Das ist geschafft. Nächster planmäßiger Halt: Training!

Und schließlich: Der Quelltext ist in meinem GitHub-Repository. Viel Spaß beim Stöbern!

Hinterlasse einen Kommentar

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