NLP Tensorflow OOV Token, Padding, create model, train model

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

Im ersten Teil wurde das Einlesen und das Vorbereiten der Daten gezeigt. Außerdem wurde ausführlich die Tokenisierung des Datensatzes besprochen. Veranschaulicht wurden die Punkte anhand eines Beispiels in Tensorflow (Python) und Tensorflow.js (Tfjs). Sowohl bei dem Python Beispiel, als auch bei dem JavaScript Beispiel kann das Modell am Ende aber nur Wörter erkennen, die mindestens einmal im Datensatz vorgekommen sind. Mit neuen Wörtern hat dieses Modell Probleme, denn wir berücksichtigen hier keinen OOV Token.

Das könnte dich auch interessieren: NLP Anwendung Teil 1 (Daten einlesen, Daten vorbereiten und Tokenisierung)

OOV Token

Bei einem Übersetzer ist es nur eine Frage der Zeit bis ein unbekanntes Wort eingegeben wird. Das kann ein Eigenname, ein Rechtschreibfehler oder ähnliches sein. Es empfiehlt sich daher das Modell auch im Hinblick auf unbekannte Wörter zu trainieren. Hierfür wird ein OOV Token benötigt. OOV steht für „out of vocabulary“. Während des Trainings lernt das Modell, diesen Token zu erzeugen oder entsprechend zu behandeln. In diesem Fall kann ein unbekanntes Wort durch den Token “<oov>” ersetzt werden, bevor es an das Modell übergeben wird. Das Modell wird es dann wie jedes andere Token behandeln und eine Antwort auf der Grundlage seines gelernten Verhaltens generieren.

Jetzt fragt man sich vielleicht wie inkludiert man diesen OOV Token in die Trainingsdaten? Ich mache das so, dass mein Datensatz zu Beginn automatisiert nach Wörtern durchsucht wird, die nur 1 Mal vorkommen. Diese seltenen Wörter ersetze ich dann durch „<oov>“, damit kann mein Modell lernen auch unbekannte Wörter zu reagieren.

Padding

Bei vielen Modellen des maschinellen Lernens, einschließlich neuronaler Netze, wird erwartet, dass die Eingaben eine feste Größe oder Form haben. Diese Anforderung ergibt sich aus der Struktur und dem Betrieb des zugrunde liegenden Berechnungsgraphen. Eingaben mit derselben Länge vereinfachen die Datenverarbeitungspipeline und ermöglichen eine effiziente Stapelverarbeitung.

Warum die Eingaben gleich lang sein sollten:

  1. Matrix-Operationen: Neuronale Netze verarbeiten Eingaben in der Regel in Stapeln, und die Stapelverarbeitung ist am effizientesten, wenn die Eingabedaten eine einheitliche Form haben. Die Daten sind in Matrizen organisiert, wobei jede Zeile eine Eingabeinstanz darstellt. Um Matrixoperationen effizient durchführen zu können, müssen alle Eingabeinstanzen die gleiche Form haben.
  2. Gemeinsame Nutzung von Parametern: In vielen neuronalen Netzwerkarchitekturen werden die Modellparameter (Gewichte) auf verschiedene Teile der Eingabesequenz verteilt. In rekurrenten neuronalen Netzen (RNNs) werden beispielsweise dieselben Gewichte für die Verarbeitung jedes Zeitschritts verwendet. Um die gemeinsame Nutzung von Parametern zu ermöglichen, müssen alle Eingabesequenzen die gleiche Länge haben.
  3. Speicherzuweisung: Neuronale Netze weisen den Speicher oft auf der Grundlage der maximalen Länge der Eingabesequenzen zu. Wenn die Sequenzen unterschiedliche Längen haben, ist eine dynamische Speicherzuweisung erforderlich, die komplexer und weniger effizient sein kann.

Es ist zwar möglich, Eingaben mit variabler Länge durch Techniken wie Auffüllen und Maskieren zu verarbeiten, aber dies erhöht die Komplexität des Modells und kann zusätzliche Verarbeitungsschritte erfordern. Der Einfachheit und Effizienz halber ist es daher üblich, Sequenzen auf eine feste Länge aufzufüllen oder abzuschneiden, bevor sie in ein neuronales Netzmodell eingespeist werden.

from keras.utils import pad_sequences

# pad sequences
encoder_seq = pad_sequences(encoder, maxlen=max_encoder_sequence_len, padding="post")
decoder_inp = pad_sequences([arr[:-1] for arr in decoder], maxlen=max_decoder_sequence_len, padding="post")
decoder_output = pad_sequences([arr[1:] for arr in decoder], maxlen=max_decoder_sequence_len, padding="post")
print(encoder_seq)
print([idx_2_txt_encoder[i] for i in encoder_seq[0]])
print([idx_2_txt_decoder[i] for i in decoder_inp[0]])
print([idx_2_txt_decoder[i] for i in decoder_output[0]])

Zur besseren Veranschaulichung habe ich die 4 print Befehle hinzugefügt. Anfangs dachte ich, dass der längste Satz im Datensatz die Länge für Input Daten und Output Daten vorgibt. Also die Paddinglänge für Input und Output gleich wäre. Das ist aber nicht der Fall! Input Daten und Output Daten sind auf unterschiedliche Längen normiert!

In dem Beispiel hier, habe ich einen winzigen Datensatz benutzt bei dem der längste englische Satz aus 3 Wörtern besteht und der längste französische Satz aus 10 Wörtern. Demnach wird mit “<pad>” bzw 0 jeder Trainingssatz aufgefüllt bis der Input 3 bzw. der Output 10 Wörter erreicht hat.

Sample output padding tensorflow

Der Decoder Output mit [arr[1:] for arr in decoder] entfernt den “start” token und der Decoder Input mit [arr[:-1] for arr in decoder] entfernt den “end” token.

Bei Sequenz-zu-Sequenz-Modellen wird der Decoder darauf trainiert, die Ausgabesequenz auf der Grundlage der Eingabesequenz und der zuvor generierten Token zu erzeugen. Während des Trainings enthält die Eingangssequenz des Decoders den “Start”-Token, der als Initialisierungs-Token für den Decoder dient. Beim Training des Decoders soll dieser jedoch den nächsten Token auf der Grundlage der zuvor generierten Token vorhersagen, mit Ausnahme des “Start”-Tokens. Daher wird bei der Vorbereitung der Decoder-Ausgabesequenz das “Start”-Token aus jeder Sequenz entfernt. Dies geschieht, um die Eingangs- und Ausgangssequenzen des Decoders korrekt aufeinander abzustimmen. Die Decoder-Eingangssequenz enthält den “Start”-Token und schließt den “End”-Token aus, während die Decoder-Ausgangssequenz den “End”-Token enthält und den “Start”-Token ausschließt. Auf diese Weise stellen wir sicher, dass der Decoder lernt, die richtige Ausgabesequenz auf der Grundlage der Eingabe zu erzeugen.

Während der Inferenz (Modellanwendung nach dem Training) bzw. der Übersetzung können wir bei der Verwendung des trainierten Modells zur Erzeugung von Übersetzungen mit dem “Start”-Token beginnen und iterativ Token erzeugen, bis wir auf das “End”-Token stoßen oder eine maximale Sequenzlänge erreichen.

Beim Padding für Tensorflow.js übernehmen wir 1:1 die Vorgehensweise von Python. Leider haben wir auch hier wieder mehr Arbeit und mehr Code Zeilen, da in Tfjs keine padSequences Funktion existiert. Ich habe mir deswegen eine eigene padSequences Funktion geschrieben:

function padSequences(sequences) {
  const paddedSequences = [];
  const maxlen = findMaxLength(sequences);

  for (const sequence of sequences) {
    if (sequence.length >= maxlen) {
      paddedSequences.push(sequence.slice(0, maxlen));
    } else {
      const paddingLength = maxlen - sequence.length;
      const paddingArray = new Array(paddingLength).fill(0);
      const paddedSequence = sequence.concat(paddingArray);
      paddedSequences.push(paddedSequence);
    }
  }

  return paddedSequences;
}

Anschließend können wir mit Hilfe dieser Funktion unseren encoder, decoder Input und decoder Output bestimmen:

function pad(data) {
  const encoderSeq = padSequences(data.en);
  const decoderInp = padSequences(data.de.map((arr) => arr.slice(0, -1))); // Has startToken
  const decoderOutput = padSequences(data.de.map((arr) => arr.slice(1))); // Has endToken
  console.log(decoderInp);
}

Bei mir ist die „1“ der „startToken“ daher sieht der decoder Input beispielsweise so aus:

Decoder Input Example JS

Modell erstellen

# Design LSTM NN (Encoder & Decoder)
# encoder model
encoder_input = Input(shape=(None,), name="encoder_input_layer")
encoder_embedding = Embedding(num_encoder_tokens, 300, input_length=max_encoder_sequence_len, name="encoder_embedding_layer")(encoder_input)
encoder_lstm = LSTM(256, activation="tanh", return_sequences=True, return_state=True, name="encoder_lstm_1_layer")(encoder_embedding)
encoder_lstm2 = LSTM(256, activation="tanh", return_state=True, name="encoder_lstm_2_layer")(encoder_lstm)
_, state_h, state_c = encoder_lstm2
encoder_states = [state_h, state_c]

# decoder model
decoder_input = Input(shape=(None,), name="decoder_input_layer")
decoder_embedding = Embedding(num_decoder_tokens, 300, input_length=max_decoder_sequence_len, name="decoder_embedding_layer")(decoder_input)
decoder_lstm = LSTM(256, activation="tanh", return_state=True, return_sequences=True, name="decoder_lstm_layer")
decoder_outputs, _, _ = decoder_lstm(decoder_embedding, initial_state=encoder_states)
decoder_dense = Dense(num_decoder_tokens+1, activation="softmax", name="decoder_final_layer")
outputs = decoder_dense(decoder_outputs)

model = Model([encoder_input, decoder_input], outputs)

Das Codebeispiel zeigt den  Entwurf eines neuronalen Netzes mit Long Short-Term Memory (LSTM) für Sequenz-zu-Sequenz-Lernen (Seq2Seq) dar, das typischerweise für Aufgaben wie maschinelle Übersetzung verwendet wird. Der Code definiert zwei Hauptteile:  Encoder-Modell und Decoder-Modell.

Encoder-Modell:

    • Die Encoder-Eingangsschicht (encoder_input) stellt die Eingangssequenz des Encoder-Modells dar.
    • Die Eingangssequenz wird mithilfe einer Einbettungsschicht (encoder_embedding) eingebettet, die jedes Token in eine dichte Vektordarstellung umwandelt.
    • Die eingebettete Sequenz wird dann durch die erste LSTM-Schicht (encoder_lstm_1_layer) geleitet, um sequenzielle Informationen zu erfassen. Die LSTM-Schicht gibt die Ausgabesequenz und den endgültigen versteckten Zustand zurück.
    • Die Ausgabesequenz der ersten LSTM-Schicht wird von der zweiten LSTM-Schicht (encoder_lstm_2_layer) weiterverarbeitet. Die zweite LSTM-Schicht liefert nur den endgültigen versteckten Zustand, der die zusammengefasste Information der Eingabesequenz darstellt.
    • Der final hidden Zustand der zweiten LSTM-Schicht wird in den final hidden Zustand (state_h) und den final Zellzustand (state_c) aufgeteilt, die als Anfangszustände für das Decodermodell verwendet werden.
    • Die Zustände des Encoder-Modells sind als encoder_states definiert und werden an das Decoder-Modell weitergegeben.

Decoder-Modell:

  • Die Decoder-Eingangsschicht (decoder_input) stellt die Eingangssequenz des Decoder-Modells dar, die aus der um eine Position verschobenen Zielsequenz besteht.
  • Ähnlich wie beim Encoder wird die Eingangssequenz mit Hilfe einer Einbettungsschicht (decoder_embedding) eingebettet.
  • Die eingebettete Sequenz wird dann durch eine LSTM-Schicht (decoder_lstm_layer) geleitet, wobei die Anfangszustände auf die Endzustände des Encoder-Modells gesetzt werden. Dies ermöglicht es dem Decoder, die relevanten Informationen des Encoders zu berücksichtigen.
  • Die LSTM-Schicht liefert die Ausgabesequenz und die Endzustände.
  • Die Ausgabesequenz aus der LSTM-Schicht wird durch eine dichte Schicht (decoder_final_layer) mit einer Softmax-Aktivierungsfunktion geleitet, die die Wahrscheinlichkeitsverteilung über die ausgegebenen Token vorhersagt.

Die Klasse Model wird zur Erstellung des Gesamtmodells verwendet, indem die Eingabeschichten ([encoder_input, decoder_input]) und die Ausgabeschicht (outputs) angegeben werden. Diese Modellarchitektur folgt der Grundstruktur eines Encoder-Decoder-Modells unter Verwendung von LSTMs, bei dem der Encoder die Eingabesequenz verarbeitet und den Kontextvektor (final hidden state) erzeugt, der dann vom Decoder zur Erzeugung der Ausgabesequenz verwendet wird.

Das selbe Modell lässt sich auch in JS umsetzen:

function createModell(
  numEncoderTokens,
  numDecoderTokens,
  maxEncoderSequenceLen,
  maxDecoderSequenceLen
) {
  // Encoder model
  const encoderInput = tf.input({ shape: [null], name: "encoderInputLayer" });
  const encoderEmbedding = tf.layers
    .embedding({
      inputDim: numEncoderTokens,
      outputDim: 300,
      inputLength: maxEncoderSequenceLen,
      name: "encoderEmbeddingLayer",
    })
    .apply(encoderInput);
  const encoderLstm = tf.layers
    .lstm({
      units: 256,
      activation: "tanh",
      returnSequences: true,
      returnState: true,
      name: "encoderLstm1Layer",
    })
    .apply(encoderEmbedding);
  const [_, state_h, state_c] = tf.layers
    .lstm({
      units: 256,
      activation: "tanh",
      returnState: true,
      name: "encoderLstm2Layer",
    })
    .apply(encoderLstm);
  const encoderStates = [state_h, state_c];

  // Decoder model
  const decoderInput = tf.input({ shape: [null], name: "decoderInputLayer" });
  const decoderEmbedding = tf.layers
    .embedding({
      inputDim: numDecoderTokens,
      outputDim: 300,
      inputLength: maxDecoderSequenceLen,
      name: "decoderEmbeddingLayer",
    })
    .apply(decoderInput);
  const decoderLstm = tf.layers.lstm({
    units: 256,
    activation: "tanh",
    returnState: true,
    returnSequences: true,
    name: "decoderLstmLayer",
  });
  const [decoderOutputs, ,] = decoderLstm.apply(decoderEmbedding, {
    initialState: encoderStates,
  });
  const decoderDense = tf.layers.dense({
    units: numDecoderTokens + 1,
    activation: "softmax",
    name: "decoderFinalLayer",
  });
  const outputs = decoderDense.apply(decoderOutputs);

  const model = tf.model({ inputs: [encoderInput, decoderInput], outputs });
  return model;
}

Modell trainieren und speichern

# train model
loss = tf.losses.SparseCategoricalCrossentropy()
model.compile(optimizer='rmsprop', loss=loss, metrics=['accuracy'])
callback = tf.keras.callbacks.EarlyStopping(monitor='loss', patience=3)
history = model.fit(
   [encoder_seq, decoder_inp],
   decoder_output,
   epochs=80,  # 80
   batch_size=450,  # 450
   # callbacks=[callback]
)

Die Funktion model.fit() wird zum Trainieren des Modells verwendet. Die Trainingsdaten bestehen aus den Encoder-Eingangssequenzen (encoder_seq), den Decoder-Eingangssequenzen (decoder_inp) und den Decoder-Ausgangssequenzen (decoder_output). Das Training wird für eine bestimmte Anzahl von Epochen (epochs) und eine Batchgröße von 450 durchgeführt. Der Trainingsfortschritt kann mit dem EarlyStopping-Callback überwacht werden, der das Training abbricht, wenn sich der Verlust nach einer bestimmten Anzahl von Epochen nicht verbessert hat. Der Trainingsverlauf wird in der Variable history gespeichert.

Das Modell in Tensorflow kann sowohl Tensoren als auch Numpy-Arrays als Eingaben verarbeiten. Wenn man Numpy-Arrays als Eingaben an die Funktion fit in TensorFlow übergibt, konvertiert diese sie intern automatisch in Tensoren, bevor das Training durchgeführt wird. Im Code werden die encoder_seq, decoder_inp und decoder_output Arrays automatisch in Tensoren umgewandelt, wenn man sie an die fit Funktion übergibt. Dies erlaubt es TensorFlow, die notwendigen Berechnungen während des Trainingsprozesses durchzuführen.

In ähnlicher Weise kann die Funktion fit in TensorFlow.js sowohl mit Tensoren als auch mit Arrays umgehen. Man kann also direkt sein 2D-Array (encoderSeq) als erste Eingabe übergeben und TensorFlow.js wird sie intern in Tensoren für das Training umwandeln. Obwohl man Arrays anstelle von Tensoren übergibt, sind TensorFlow und TensorFlow.js in der Lage, die Konvertierung intern zu handhaben und das Training entsprechend durchzuführen.

# save model
model.save("./model-experimental/Translate_Eng_FR.h5")
model.save_weights("./model-experimental/model_NMT")

Es ist üblich, die Gewichte eines trainierten Modells getrennt von der Modellarchitektur zu speichern. Die separate Speicherung der Gewichte und der Architektur ermöglicht mehr Flexibilität beim Laden und Verwenden des Modells. So kann man beispielsweise nur die Gewichte laden, wenn die Modellarchitektur an anderer Stelle definiert wurde oder wenn die Gewichte in einem anderen Modell mit einer ähnlichen Architektur verwenden werden sollen.

Abschließend auch der Code in JavaScript:

async function trainModel(data) {
  const encoderSeq = padSequences(data.en);
  const decoderInp = padSequences(data.de.map((arr) => arr.slice(0, -1))); // Has startToken
  const decoderOutput = padSequences(data.de.map((arr) => arr.slice(1))); // Has endToken

  data.model.compile({
    optimizer: "rmsprop",
    loss: "sparseCategoricalCrossentropy",
    metrics: ["accuracy"],
  });
  const history = await data.model.fit(
    [encoderSeq, decoderInp],
    decoderOutput,
    {
      epochs: 80,
      batch_size: 450,
    }
  );
}

An dieser Stelle kommt meine Frustration mit Tensorflow.js ins Spiel. Obwohl jeder Schritt 1:1 dem Schritt in Python entspricht klappt das Training des Modells in Tensorflow.js nicht… Ich erhalte immer eine Fehlermeldung:

C:\Users\[...]\node_modules\@tensorflow\tfjs-layers\dist\tf-layers.node.js:23386
            if (array.shape.length !== shapes[i].length) {
                            ^

TypeError: Cannot read properties of undefined (reading 'length')
    at standardizeInputData

Allgemein Verlustfunktion und Optimierer

Verlustfunktionen und Optimierer sind Schlüsselkomponenten beim Training eines maschinellen Lernmodells. Eine Verlustfunktion, die auch als Ziel- oder Kostenfunktion bezeichnet wird, misst die Leistung eines Modells, indem sie die Unähnlichkeit zwischen den vorhergesagten Ausgaben und den tatsächlichen Zielen quantifiziert. Das Ziel des Trainings eines Modells ist es, diese Verlustfunktion zu minimieren, was im Wesentlichen bedeutet, dass die Fähigkeit des Modells, genaue Vorhersagen zu treffen, verbessert wird. Die Wahl der Verlustfunktion hängt von der jeweiligen Problemstellung ab. Bei Klassifizierungsaufgaben sind beispielsweise die kategoriale Kreuzentropie, die binäre Kreuzentropie und die Softmax-Kreuzentropie gängige Verlustfunktionen, während bei Regressionsaufgaben häufig der mittlere quadratische Fehler (MSE) und der mittlere absolute Fehler (MAE) verwendet werden.

Ein Optimierer hingegen ist für die Aktualisierung der Modellparameter (Gewichte und Verzerrungen) während des Trainings verantwortlich, um die Verlustfunktion zu minimieren. Er bestimmt, wie die Parameter des Modells auf der Grundlage der berechneten Gradienten der Verlustfunktion in Bezug auf diese Parameter angepasst werden. Optimierer verwenden verschiedene Algorithmen und Techniken, um effizient nach den optimalen Werten der Parameter zu suchen. Zu den gängigen Optimierern gehören Stochastic Gradient Descent (SGD), Adam, RMSprop und Adagrad. Jeder Optimierer hat seine eigenen Merkmale und Hyperparameter, die den Trainingsprozess und die Konvergenzgeschwindigkeit des Modells beeinflussen können.

Die Wahl der Verlustfunktion und des Optimierers hängt von der spezifischen Aufgabe, der Modellarchitektur und den Eigenschaften des Datensatzes ab. Es ist wichtig, geeignete Verlustfunktionen und Optimierer auszuwählen, um ein effektives Modelltraining und eine Konvergenz zur optimalen Leistung zu gewährleisten.

Häufig verwendete Verlustfunktionen und Optimierer

Verlustfunktionen:

  • Kategoriale Kreuz-Entropie: Diese Verlustfunktion wird häufig in Sequenz-zu-Sequenz-Modellen für Mehrklassen-Klassifizierungsprobleme verwendet, bei denen jedes Zielwort als eine eigene Klasse behandelt wird.
  •  Spärliche kategoriale Kreuzentropie: Ähnlich wie die kategoriale Kreuzentropie, aber geeignet, wenn die Zielsequenzen als spärliche ganzzahlige Sequenzen dargestellt werden (z. B. unter Verwendung von Wortindizes).

Optimierer:

  • Adam: Adam ist ein beliebter Optimierer, der die Vorteile des Adaptiven Gradientenalgorithmus (AdaGrad) und der Root Mean Square Propagation (RMSprop) kombiniert. Er passt die Lernrate für jeden Parameter auf der Grundlage früherer Gradienten an, was zu einer schnelleren Konvergenz und einer besseren Handhabung spärlicher Gradienten beiträgt.
  • RMSprop: RMSprop ist ein Optimierer, der einen gleitenden Durchschnitt der quadrierten Gradienten für jeden Parameter beibehält. Er passt die Lernrate auf der Grundlage der Größe des Gradienten an, was eine schnellere Konvergenz und eine bessere Leistung bei nicht stationären Zielen ermöglicht.
  • Adagrad: Adagrad passt die Lernrate individuell für jeden Parameter an, basierend auf der historischen Gradientenakkumulation. Es führt größere Updates für seltene Parameter und kleinere Updates für häufige Parameter durch.

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