NLP Tensorflow Read in Data, clean data and tokenization

NLP Anwendung: Tensorflow.js vs Tensorflow Python – Teil 1

Ich arbeite aktuell an einem Projekt bei dem ich einen Deutsch zu Bairisch Übersetzer mittels Machine Learning programmieren will. Man bezeichnet das als Natural Language Processing (NLP). Häufig wird für die Umsetzung eine Google Library namens Tensorflow benutzt. Es existiert sowohl Tensorflow.js als auch Tensorflow (Python). Da ich beruflich mit Angular entwickle und daher mit TypeScript bzw. JavaScript vertraut bin, habe ich mich anfangs direkt für die NLP Anwendung in Tensorflow.js entschieden. Ich war so naiv anzunehmen, dass der einzige Unterschied zwischen den beiden Libraries die verwendete Programmiersprache wäre. Das ist definitiv nicht der Fall! Für mein NLP Projekt fehlen teilweise grundlegende Funktionen in Tensorflow.js (wie beispielsweise ein Tokenizer). In diesem Beitrag habe ich die allgemeinen Unterschiede zwischen Tensorflow.js und Tensorflow (Python) erklärt.

Ich habe viele Abende damit verbracht mein Projekt mit Tensorflow.js zum Laufen zu bringen und bin am Ende gescheitert. Der Wechsel auf Python brachte dann den erhofften Durchbruch! Ich würde jedem empfehlen für NLP Anwendungen Python zu nutzen! Nichtsdestotrotz möchte ich in diesem Beitrag einmal die Unterschiede zwischen Tensorflow.js und Tensorflow im Bezug auf mein Projekt anhand von Codebeispielen verdeutlichen. Zwischendurch werde ich auch so gut wie möglich mein neu angesammeltes Wissen in die jeweiligen Abschnitte einbauen.

Das könnte dich auch interessieren: NLP Anwendung Teil 2 (OOV Token, Padding, Modell erstellen und Modell trainieren)

Daten einlesen

Zunächst einmal braucht man einen Datensatz mit dem später das Modell trainiert werden soll. Hier kann ich https://www.kaggle.com/ empfehlen. Man findet dort eine Vielzahl an Datensätzen zur freien Verfügung und teilweise sogar weiterführende Codebeispiele. Den Datensatz kann man entweder per Link einlesen oder downloaden und dann lokal vom File System einlesen. Ein guter Datensatz sollte über 100.000 Beispiele enthalten. Am besten auch teilweise ganze Paragraphen. So sieht beispielsweise ein Englisch/Französischer Datensatz als CSV aus:

Sample dataset tensorflow CSV

Zunächst die simple Variante mittels Python:

import pandas as pd

# read in dataSet for training
df = pd.read_csv("./dataset/eng_-french.csv")
df.columns = ["english", "french"]
print(df.head())
print(df.info())

Wir nutzen die pandas Library und lesen damit die CSV ein. Mit dem head() können wir testen ob es funktioniert hat und uns die ersten 5 Zeilen anzeigen lassen. Mittels info() erhalten wir weitere Infos wie beispielsweise Anzahl der Spalten und Anzahl der Reihen:

output CSV read in with python pandas lib

Zum Vergleich in Tensorflow.js (Tfjs) gibt es auch eine Möglichkeit CSV einzulesen:

const tf = require("@tensorflow/tfjs");

async function readInData() {
  await tf.ready();
  const languageDataSet = tf.data.csv("file://" + "./ger_en_trans.csv");

  // Extract language pairs
  const dataset = languageDataSet.map((record) => ({
    en: record.en,
    de: record.de,
  }));

  const pairs = await dataset.toArray();

  console.log(pairs);
}

readInData();

Ich habe dabei zunächst versucht den gleichen Datensatz wie in der Python Version einzulesen:

read in same CSV as in tf

Anschließend wollte ich die Überschriften kürzen in der originalen CSV, dadurch bekam ich aber seltsamer Weise eine Fehlermeldung beim Einlesen. Selbst als ich den Originalzustand der CSV wiederhergestellt hatte, blieb der Fehler:

error when reading in CSV

Letztlich habe ich mich dann dafür entschieden einen anderen Datensatz zu nutzen:

other CSV data samples

Der war beim Einlesen auch wesentlich besser lesbar:

NLP Anwendung Tensorflow.js

Und hier das Endergebnis nach dem Mapping:

after mapping csv

Obwohl Tfjs extra eine Funktion zum Einlesen der CSV bietet, hatte ich trotzdem schon mehr Ärger als in der Python Version. Ich habe auch auf die Schnelle keine Möglichkeit gefunden einen Datensatz im txt-Format einzulesen. Allerdings sind txt-Datensätze weit verbreitet!

Daten vorbereiten

Ich habe oft gesehen, dass für die Datenvorbereitung eine cleaning Funktion geschrieben wurde und der Output Satz auch einen Start und End Token erhalten hat. Daraufhin habe ich mich gefragt ob auch der Input Satz also der Encoder einen Start und End Token braucht. Im Rahmen von Sequenz-zu-Sequenz-Modellen benötigt der Encoder aber keine expliziten Start- und End-Token. Sein Zweck ist es, die Eingabesequenz so zu verarbeiten, wie sie ist, und eine Darstellung der Eingabe zu erstellen.

Der Decoder hingegen, der die Ausgabesequenz erzeugt, profitiert in der Regel von der Verwendung von Start- und End-Tokens. Diese Token helfen, den Anfang und das Ende der erzeugten Sequenz zu kennzeichnen. Die Verwendung von Start- und End-Token ist daher spezifisch für den Decoder. Während des Trainings enthält die Eingangssequenz des Decoders ein Start-Token am Anfang und schließt ein End-Token am Ende aus. Die Ausgabesequenz des Decoders enthält den End-Token und schließt den Start-Token aus. Auf diese Weise lernt das Modell, die richtige Ausgabesequenz auf der Grundlage der Eingabe zu erzeugen.

Bei der Erstellung von Übersetzungen mit dem trainierten Modell beginnt man mit dem Start-Token und generiert ein Token nach dem anderen, bis man auf den End-Token trifft oder eine maximale Sequenzlänge erreicht. Das Hinzufügen von Start- und End-Token zum Decoder-Satz verbessert die Leistung des NLP-Übersetzermodells. Es hilft bei der Festlegung klarer Sequenzgrenzen und unterstützt den Generierungsprozess, indem es angibt, wo die Übersetzung beginnt und endet.

Zusammengefasst:

  • Encoder: Keine Notwendigkeit für Start- und End-Token. Verarbeitet die Eingabesequenz wie sie ist.
  • Decoder: Start- und End-Token sind hilfreich für die Generierung der Ausgabesequenz.

Wir beginnen wieder mit dem einfachen Teil, nämlich Python. Wie wollen die eingelesenen Daten säubern. Das bedeutet alles in Kleinbuchstaben umwandeln und Zeichen, die nicht zum Alphabet gehören oder Satzzeichen sind, entfernen. Dafür brauchen wir die Regex Bibliothek (re).

import re

def clean(text):
    text = text.lower()  # lower case
    # remove any characters not a-z and ?!,'
    # please note that french has additional characters...I just simplified that
    text = re.sub(u"[^a-z!?',]", " ", text)
    return text


# apply cleaningFunctions to dataframe
data["english"] = data["english"].apply(lambda txt: clean(txt))
data["french"] = data["french"].apply(lambda txt: clean(txt))

# add <start> <end> token to decoder sentence (french)
data["french"] = data["french"].apply(lambda txt: f"<start> {txt} <end>")

print(data.sample(10))

Ich habe hier vereinfacht. Da es sich um einen französischen Datensatz handelt sollte man eigentlich eine extra cleaning Funktion schreiben, die auch französische Buchstaben wie „ê“ berücksichtigt. Die  sample() Funktion dient nur zum veranschaulichen der Daten:

tensorflow random sample of cleaned data

In Tfjs ist der Ablauf absolut identisch. Ich habe eine cleanData() Funktion erstellt und den vorherigen Code modifiziert:

function cleanData(text) {
  //if necessary also remove any characters not a-z and ?!,'
  return text.toLowerCase();
}
const dataset = languageDataSet.map((record) => ({
   en: cleanData(record.en),
   de: "startToken " + cleanData(record.de) + " endToken",
 }));

Das Ergebnis ist daher auch identisch zum Python Ansatz:

cleaned up tfjs data

Wenn die Wörter “start” und “end” Teil regulärer Sätze sind und nicht als spezielle Token zur Markierung von Anfang und Ende von Sequenzen verwendet werden, dann sollten sie bei der Tokenisierung auf keinen Fall durch entsprechende Indizes ersetzt werden. Bei der Tokenisierung ist es wichtig, spezielle Token zu wählen, die in den eigentlichen Eingabedaten wahrscheinlich nicht vorkommen werden. Dadurch wird sichergestellt, dass das Modell sie von normalen Wörtern unterscheiden kann und lernt, die entsprechenden Ausgabesequenzen zu erzeugen.

Wenn die Wörter ” start” und “end” reguläre Wörter in den Eingabesätzen sind, sollte man in Erwägung ziehen, verschiedene spezielle Token zu verwenden, um den Anfang und das Ende von Sequenzen zu markieren. Eine gängige Wahl sind ” <start>” und “<end>”. Durch die Verwendung spezieller Token, die wahrscheinlich nicht zum regulären Vokabular gehören, kann sichergestellt werden, dass sie vom Modell während des Trainings und der Generierung richtig identifiziert und verarbeitet werden können.

Beispielsweise würden die tokenisierten Sequenzen wie folgt aussehen:

Decoder Eingabe: [“<start>”, “hallo”, “welt”]
Decoder Ausgabe: [“hallo”, “welt”, “<end>”]

Daher nachfolgendes VERMEIDEN:

Decoder Eingabe: [“start”, “hallo”, “welt”]
Decoder Ausgabe: [“hallo”, “welt”, “end”]

Tokenisierung

# tokenization
import tensorflow as tf
from tensorflow import keras
from keras.preprocessing.text import Tokenizer
import numpy as np

# english tokenizer
english_tokenize = Tokenizer(filters='#$%&()*+,-./:;<=>@[\\]^_`{|}~\t\n')
english_tokenize.fit_on_texts(data["english"])
num_encoder_tokens = len(english_tokenize.word_index)+1
# print(num_encoder_tokens)
encoder = english_tokenize.texts_to_sequences(data["english"])
# print(encoder[:5])
max_encoder_sequence_len = np.max([len(enc) for enc in encoder])
# print(max_encoder_sequence_len)

# french tokenizer
french_tokenize = Tokenizer(filters="#$%&()*+,-./:;<=>@[\\]^_`{|}~\t\n")
french_tokenize.fit_on_texts(data["french"])
num_decoder_tokens = len(french_tokenize.word_index)+1
# print(num_decoder_tokens)
decoder = french_tokenize.texts_to_sequences(data["french"])
# print(decoder[:5])
max_decoder_sequence_len = np.max([len(dec) for dec in decoder])
# print(max_decoder_sequence_len)

Dieser Code führt Tokenisierung und Sequenzvorverarbeitung mit der Tokenizer Klasse in TensorFlow durch.

  1. english_tokenize = Tokenizer(filters=’#$%&()*+,-./:;<=>@[\\]^_`{|}~\t\n’) Initialisiert ein Tokenizer-Objekt für englische Sätze. Der Parameter `filters` gibt Zeichen an, die während der Tokenisierung herausgefiltert werden sollen. Wir haben die Daten im Cleaning Prozess bereits gefiltert, es ist daher eigentlich nicht notwendig hier nochmal zu filtern.
  2. english_tokenize.fit_on_texts(data[“english”]) Aktualisiert das interne Vokabular des Tokenizers basierend auf den englischen Sätzen in der Variable data. Jedem Wort im Vokabular wird ein eindeutiger Index zugewiesen.
  3. num_encoder_tokens = len(english_tokenize.word_index) + 1 Bestimmt die Anzahl der eindeutigen Token (Wörter) im englischen Vokabular. Das Attribut word_index des Tokenizers gibt ein Wörterbuch zurück, das Wörter auf ihre jeweiligen Indizes abbildet.
  4. encoder = english_tokenize.texts_to_sequences(data[“english”]) Konvertiert die englischen Sätze in der Variablen data in Sequenzen von Token-Indizes unter Verwendung des Tokenizers. Jeder Satz wird durch eine Folge von Ganzzahlen ersetzt, die die entsprechenden Wörter darstellen.
  5. max_encoder_sequence_len = np.max([len(enc) for enc in encoder]) Berechnet die maximale Länge (Anzahl der Token) unter allen kodierten Sequenzen. Sie verwendet die Funktion max von NumPy, um den maximalen Wert in einem Listenverständnis zu finden.

Diese Schritte helfen, die Sätze für die weitere Verarbeitung in einem NLP-Modell vorzubereiten. Das ist für beide Sprachen notwendig!

Die Sätze wurden jetzt in Token umgewandelt, anschließend in Sequenzen von Token-Indizes konvertiert und die maximale Sequenzlänge bestimmt. Ein Beispielsatz aus dem Datensatz sieht jetzt so aus: [[148], [252], [59], [14], [111]]. Hierbei könnte die 148 für „I“, 252 für „am“, 59 für „very“, 14 für „hungry“ und 111 für „now“ stehen.

idx_2_txt_decoder = {k: i for i, k in french_tokenize.word_index.items()}
# print(idx_2_txt_decoder)
idx_2_txt_encoder = {k: i for i, k in english_tokenize.word_index.items()}
# print(idx_2_txt_encoder)

idx_2_txt_decoder[0] = "<pad>"
idx_2_txt_encoder[0] = "<pad>"

Der Codeschnipsel idx_2_txt_encoder = {k: i for i, k in english_tokenize.word_index.items()} erstellt ein Wörterbuch Verzeichnis idx_2_txt_encoder, das Token-Indizes den entsprechenden Wörtern im englischen Vokabular zuordnet: {k: i for i, k in english_tokenize.word_index.items()} ist ein Verzeichnis, das über die Schlüssel-Wert-Paare in english_tokenize.word_index iteriert. Bei jeder Iteration steht der Key (k) für ein Wort im Vokabular, und der Wert (i) für den entsprechenden Index. Das Verständnis erstellt ein neues Wörterbuch, dessen Keys die Indizes (i) und die Werte die Wörter (k) sind.

index 2 tokenizer dicitonary sample

Das resultierende idx_2_txt_encoder – Wörterbuch ermöglicht es, das Wort, das einem bestimmten Index entspricht, im englischen Wortschatz nachzuschlagen. english_tokenize.word_index würde übrigens die Anzeigen genau vertauschen. Hier wäre der Key das Wort sein und der Wert der Index. Die zweite Zeile, idx_2_txt_encoder[0] = “<pad>”, fügt dem Wörterbuch einen speziellen Eintrag hinzu. Hier wird das Wort “<pad>” dem Index “0” zugeordnet, um einen Auffüllungs-Token anzugeben, der beim Padding von Sequenzen verwendet wird.

Anschließend sollte man das Wörterbuch Verzeichnis abspeichern, denn später wenn das Modell trainiert wurde und eingesetzt wird, werden die Übersetzungen des Modells ebenfalls eine Reihe von Indizes sein, die mit Hilfe des Wörterbuchs in lesbare Sätze zurücktransformiert werden.

# Saving the dicitionaries
pickle.dump(idx_2_txt_encoder, open("./saves/idx_2_word_input.txt", "wb"))
pickle.dump(idx_2_txt_decoder, open("./saves/idx_2_word_target.txt", "wb"))

Der gleiche Ablauf wie in Python lässt sich auch für die NLP Anwendung in Tensorflow.js konstruieren. Selbstverständlich benötigt man etwas mehr Codezeilen und der Arbeitsaufwand ist insgesamt höher. Die erste Hürde hierbei ist der Tokenizer. Leider besitzt Tfjs im Gegensatz zu Tensorflow (Python) keinen eigenen Tokenizer. Nach ausgiebiger Recherche fand ich zum Glück den natural.WordTokenizer. Hierbei möchte ich darauf hinweisen, dass definitv ein Node.js Projekt benötigt wird. Tfjs lässt sich zwar über einen <script> Tag einbinden, der natural.WordTokenizer, dagegen aber nicht!

Ein weiterer wichtiger Punkt ist, dass der WordTokenizer “<” und “>” entfernt. Aus einem Output Satz “<start> ich esse <end>” wird daher einfach [‘start’, ‘ich’, ‘esse’, ‘end’]. Somit ist der “<start>” und “<end>” Token nicht mehr klar erkennbar! Ich habe sie daher im JS Code von Anfang ersetzt durch “startToken” und “endToken”.

Zunächst tokenisieren wir wieder jeden einzelnen Satz aus dem Datensatz und erstellen anschließend ein Vokabelverzeichnis für jede der beiden Sprachen. Abschließend ersetzen wir alle Wörter durch die Indizes aus dem Vokabelverzeichnis:

const natural = require("natural");

function tokenize(data) {
  const tokenizer = new natural.WordTokenizer();

  enData = data.map((row) => tokenizer.tokenize(row.en));
  deData = data.map((row) => tokenizer.tokenize(row.de));

  const enVocabulary = new Map();
  const deVocabulary = new Map();

  // Insert <pad> at index 0
  enVocabulary.set("<pad>", 0);
  deVocabulary.set("<pad>", 0);

  const fillVocabulary = (langData, vocabMap) => {
    langData.forEach((sentence) => {
      sentence.forEach((word) => {
        if (!vocabMap.has(word)) {
          const newIndex = vocabMap.size;
          vocabMap.set(word, newIndex);
        }
      });
    });
  };

  fillVocabulary(enData, enVocabulary);
  fillVocabulary(deData, deVocabulary);

  // Replace words with indexes
  const indexedEnData = enData.map((element) =>
    element.map((word) => enVocabulary.get(word))
  );
  const indexedDeData = deData.map((element) =>
    element.map((word) => deVocabulary.get(word))
  );

  return { en: indexedEnData, de: indexedDeData };
}

Um später die Resultate unseres Modells wieder in Wörter umwandeln zu können und um auch später im realen Anwendungsfall das Modell nutzen zu können, speichern wir die beiden Vokabelverzeichnisse ab. Ich habe dabei die Key und Value Paare getauscht, aber letztlich ist das nicht zwingend erforderlich:

const fs = require("fs");

// store the input and output key value pairs
  fs.writeFileSync(
    "vocabulary/inputVocableSet.json",
    JSON.stringify(switchKeysAndValues(Object.fromEntries(enVocabulary)))
  );
  fs.writeFileSync(
    "vocabulary/outputVocableSet.json",
    JSON.stringify(switchKeysAndValues(Object.fromEntries(deVocabulary)))
  );

function switchKeysAndValues(obj) {
  const switchedObj = {};
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      const value = obj[key];
      switchedObj[value] = key;
    }
  }
  return switchedObj;
}

Als Resultat erhalten wir ein JSON Objekt mit unseren Vokabeln:

excerpt of the stored json map

Anschließend returnen wir das Ergebnis unserer Funktion:

result after tokenization

Dateien zum Herunterladen

Schreibe einen Kommentar

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

Cookie Consent mit Real Cookie Banner