Pytorch
TensorFlow

#
Vorbereitung der Daten

Wir fahren mit dem Beispiel aus dem vorigen Kapitel fort. Folgenderweise würden wir einen Sequenzklassifikator mit einem Batch in PyTorch trainieren:

    import torch
    from transformers import AdamW, AutoTokenizer, AutoModelForSequenceClassification

    # Genau wie vorher
    checkpoint = "bert-base-uncased"
    tokenizer = AutoTokenizer.from_pretrained(checkpoint)
    model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
    sequences = [
        "I've been waiting for a HuggingFace course my whole life.",  # Ich habe mein ganzes Leben auf einen HuggingFace-Kurs gewartet.
        "This course is amazing!",  # Dieser Kurs ist fantastisch!
    ]
    batch = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")

    # Dies ist neu
    batch["labels"] = torch.tensor([1, 1])

    optimizer = AdamW(model.parameters())
    loss = model(**batch).loss
    loss.backward()
    optimizer.step()

Natürlich würde das Training von Modellen mit nur zwei Sätzen keine sonderlich guten Ergebnisse liefern. Um bessere Ergebnisse zu erzielen, müssen wir einen größeren Datensatz vorbereiten.

In diesem Abschnitt verwenden wir den MRPC-Datensatz (Microsoft Research Paraphrase Corpus) als Beispiel. Dieser wurde in einem Paper von William B. Dolan und Chris Brockett veröffentlicht. Der Datensatz besteht aus insgesamt 5.801 Satzpaaren und enthält ein Label, das angibt, ob es sich bei einem Paar um Paraphrasen handelt (d.h. ob beide Sätze dasselbe bedeuten). Wir haben diesen Datensatz für dieses Kapitel ausgewählt, weil es sich um einen kleinen Datensatz handelt, sodass es einfach ist, während dem Training zu experimentieren.

###
Laden eines Datensatzes vom Hub

Das Hub enthält nicht nur Modelle; Es hat auch mehrere Datensätze in vielen verschiedenen Sprachen. Du kannst die Datensätze hier durchsuchen, und wir empfehlen, einen weiteren Datensatz zu laden und zu verarbeiten, sobald Sie diesen Abschnitt abgeschlossen haben (die Dokumentation befindet sich hier). Aber jetzt konzentrieren wir uns auf den MRPC-Datensatz! Dies ist einer der 10 Datensätze, aus denen sich das GLUE-Benchmark zusammensetzt. Dies ist ein akademisches Benchmark, das verwendet wird, um die Performance von ML-Modellen in 10 verschiedenen Textklassifizierungsaufgaben zu messen.

Die Bibliothek 🤗 Datasets bietet einen leichten Befehl zum Herunterladen und Caching eines Datensatzes aus dem Hub. Wir können den MRPC-Datensatz wie folgt herunterladen:

    from datasets import load_dataset

    raw_datasets = load_dataset("glue", "mrpc")
    raw_datasets

    DatasetDict({
        train: Dataset({
            features: ['sentence1', 'sentence2', 'label', 'idx'],
            num_rows: 3668
        })
        validation: Dataset({
            features: ['sentence1', 'sentence2', 'label', 'idx'],
            num_rows: 408
        })
        test: Dataset({
            features: ['sentence1', 'sentence2', 'label', 'idx'],
            num_rows: 1725
        })
    })

Wie du sehen kannst, erhalten wir ein `DatasetDict`-Objekt, das die Trainingsdaten, die Validierungsdaten und die Testdaten enthält. Jedes Objekt enthält mehrere Spalten (`sentence1`, `sentence2`, `label` und `idx`) und eine unterschiedliche Anzahl an Zeilen, dies ist die Anzahl der Elemente in jedem Datensatz (also gibt es 3.668 Satzpaare in den Trainingsdaten, 408 in den Validierungsdaten und 1.725 in den Testdaten).

Dieser Befehl lädt das Dataset herunter und speichert es im Cache, standardmäßig in ~/.cache/huggingface/dataset. Wir Erinnern uns an Kapitel 2, dass der Cache-Ordner anpasst werden kann, indem man die Umgebungsvariable `HF_HOME` setzt.

Wir können auf jedes Satzpaar in unserem `raw_datasets`-Objekt zugreifen, indem wir wie bei einem Dictionary einen Schlüsselwert als Index verwenden:

    raw_train_dataset = raw_datasets["train"]
    raw_train_dataset[0]

    {'idx': 0,
     'label': 1,
     'sentence1': 'Amrozi accused his brother , whom he called " the witness " , of deliberately distorting his evidence .',
     'sentence2': 'Referring to him as only " the witness " , Amrozi accused his brother of deliberately distorting his evidence .'}

Wir stellen fest, dass die Labels bereits Ganzzahlen sind, sodass wir dort keine Vorverarbeitung durchführen müssen. Wir können die `features` von `raw_train_dataset` untersuchen, um zu erfahren, welche Ganzzahl welchem Label entspricht. Der folgende Befehl gibt uns den Variablentyp zurück:

    raw_train_dataset.features

    {'sentence1': Value(dtype='string', id=None),
     'sentence2': Value(dtype='string', id=None),
     'label': ClassLabel(num_classes=2, names=['not_equivalent', 'equivalent'], names_file=None, id=None),
     'idx': Value(dtype='int32', id=None)}

Hinter den Kulissen ist `label` vom Typ `ClassLabel`, und die Zuordnung von Ganzzahlen zum Labelnamen wird im Ordner names gespeichert. `0` entspricht `not_equivalent`, also “nicht äquivalent”, und `1` entspricht `equivalent`, also “äquivalent”.

✏️ Probier es aus! Sieh dir das Element 15 der Trainingsdaten und Element 87 des Validierungsdaten an. Was sind ihre Labels?

###
Vorverarbeitung eines Datensatzes

Um den Datensatz vorzubereiten, müssen wir den Text in Zahlen umwandeln, die das Modell sinnvoll verarbeiten kann. Im vorherigen Kapitel haben wir gesehen, dass dies mit einem Tokenizer gemacht wird. Wir können den Tokenizer mit einem Satz oder einer Liste von Sätzen füttern, sodass wir die ersten und zweiten Sätze jedes Paares wie folgt direkt tokenisieren können:

    from transformers import AutoTokenizer

    checkpoint = "bert-base-uncased"
    tokenizer = AutoTokenizer.from_pretrained(checkpoint)
    tokenized_sentences_1 = tokenizer(raw_datasets["train"]["sentence1"])
    tokenized_sentences_2 = tokenizer(raw_datasets["train"]["sentence2"])

Wir können jedoch nicht einfach zwei Sequenzen an das Modell übergeben und eine Vorhersage erhalten, ob die beiden Sätze paraphrasiert sind oder nicht. Wir müssen die beiden Sequenzen als Paar behandeln und die entsprechende Vorverarbeitung anwenden. Glücklicherweise kann der Tokenizer auch ein Sequenzpaar nehmen und es so vorbereiten, wie es unser BERT-Modell erwartet:

    inputs = tokenizer("This is the first sentence.", "This is the second one.")
    inputs

    {
      'input_ids': [101, 2023, 2003, 1996, 2034, 6251, 1012, 102, 2023, 2003, 1996, 2117, 2028, 1012, 102],
      'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
      'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    }

In Kapitel 2 haben wir die Schlüsselwerte `input_ids` und `attention_mask` behandelt, allerdings haben wir es aufgeschoben, über `token_type_ids` zu sprechen. In diesem Beispiel teilt diese dem Modell mit, welcher Teil des Input der erste Satz und welcher der zweite Satz ist.

✏️ Probier es aus! Nimm Element 15 der Trainingsdaten und tokenisiere die beiden Sätze separat und als Paar. Wo liegt der Unterschied zwischen den beiden Ergebnissen?

Wenn wir die IDs in `input_ids` zurück in Worte dekodieren:

    tokenizer.convert_ids_to_tokens(inputs["input_ids"])

dann bekommen wir:

    ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']

Wir sehen also wenn es zwei Sätze gibt, dass das Modell erwartet, dass die Inputs die Form ”[CLS] Satz1 [SEP] Satz2 [SEP]” haben. Wenn wir dies mit den `token_type_ids` abgleichen, erhalten wir:

    ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
    [      0,      0,    0,     0,       0,          0,   0,       0,      1,    1,     1,        1,     1,   1,       1]

Wie du sehen kannst, haben die Teile der Eingabe, die `[CLS] Satz1 [SEP]` entsprechen, alle eine Token-Typ-ID von `0`, während die anderen Teile, die `Satz2 [SEP]` entsprechen, alle einer Token-Typ-ID von `1` enthalten.

Beachte, dass die Auswahl eines anderen Checkpoints nicht unbedingt die `token_type_ids` in Ihren tokenisierten Inputs haben (z.B. werden sie nicht zurückgegeben, wenn ein DistilBERT-Modell verwendet wird). Sie werden nur zurückgegeben, wenn das Modell weiß was damit zu tun ist, weil es die Toke-Typ-Ids während des Vortrainings gesehen hat.

In diesem Fall ist BERT mit Token-Typ-IDs vortrainiert worden, und zusätzlich zu dem maskierten Sprachmodellierungsziel aud Kapitel 1, hat es ein zusätzliches Vorhersageziel namens next sentence prediction (d.h. Vorhersage des nächsten Satzes). Das Ziel dieser Aufgabe ist es, die Beziehung zwischen Satzpaaren zu modellieren.

Bei der Vorhersage des nächsten Satzes werden dem Modell Satzpaare (mit zufällig maskierten Token) bereitgestellt und erwartet, vorherzusagen, ob auf den ersten Satz der zweite Satz folgt. Um die Aufgabe non-trivial zu machen, folgen sich die Hälfte der Sätze in dem Originaldokument, aus dem sie extrahiert wurden, aufeinander, und in der anderen Hälfte stammen die beiden Sätze aus zwei verschiedenen Dokumenten.

Im Allgemeinen muss man sich keine Gedanken darüber machen, ob Ihre tokenisierten Inputs `token_type_ids` enthalten oder nicht: Solange du denselben Checkpoint für den Tokenizer und das Modell verwendest, ist alles in Ordnung, da der Tokenizer weiß, was er dem Modell bereitstellen soll.

Nachdem wir nun gesehen haben, wie unser Tokenizer mit einem Satzpaar umgehen kann, können wir damit unseren gesamten Datensatz tokenisieren: Wie im vorherigen Kapitel können wir dem Tokenizer eine Liste von Satzpaaren einspeisen, indem du ihm die Liste der ersten Sätze und dann die Liste der zweiten Sätze gibst. Dies ist auch kompatibel mit den Optionen zum Padding und Trunkieren, die wir in Kapitel 2 gesehen haben. Eine Möglichkeit, den Trainingsdatensatz vorzuverarbeiten, ist also:

    tokenized_dataset = tokenizer(
        raw_datasets["train"]["sentence1"],
        raw_datasets["train"]["sentence2"],
        padding=True,
        truncation=True,
    )

Das funktioniert gut, hat aber den Nachteil, dass ein Dictionary zurückgegeben wird (mit unseren Schlüsselwörtern `input_ids`, `attention_mask` und `token_type_ids` und Werten aus Listen von Listen). Es funktioniert auch nur, wenn du genügend RAM hast, um den gesamten Datensatz während der Tokenisierung zu im RAM zwischen zu speichern (während die Datensätze aus der Bibliothek 🤗 Datasets Apache Arrow Dateien sind, die auf der Festplatte gespeichert sind, sodass nur die gewünschten Samples im RAM geladen sind).

Um die Daten als Datensatz zu speichern, verwenden wir die Methode `Dataset.map()`. Dies gewährt uns zusätzliche Flexibilität, wenn wir zusätzliche Vorverarbeitung als nur die Tokenisierung benötigen. Die `map()`-Methode funktioniert, indem sie eine Funktion auf jedes Element des Datensatzes anwendet, also definieren wir eine Funktion, die unsere Inputs tokenisiert:

    def tokenize_function(example):
        return tokenizer(example["sentence1"], example["sentence2"], truncation=True)

Diese Funktion nimmt ein Dictionary (wie die Elemente unseres Datensatzes) und gibt ein neues Dictionary mit den Schlüsselwerten `input_ids`, `attention_mask` und `token_type_ids` zurück. Beachte, dass es auch funktioniert, wenn das `example`-Dictionary mehrere Beispiele enthält (jeder Schlüsselwert als Liste von Sätzen), da der `Tokenizer`, wie zuvor gesehen, mit Listen von Satzpaaren arbeitet. Dadurch können wir die Option `batched=True` in unserem Aufruf von `map()` verwenden, was die Tokenisierung erheblich beschleunigt. Der `tokenizer` wurde in Rust geschriebenen und ist in der Bibliothek 🤗 Tokenizers verfügbar. Dieser Tokenizer kann sehr schnell arbeiten, wenn wir ihm viele Inputs auf einmal zum Verarbeiten geben. Note that we’ve left the `padding` argument out in our tokenization function for now.

Beachte, dass wir das `padding`-Argument vorerst in unserer Tokenisierungsfunktion ausgelassen haben. Dies liegt daran, dass das Anwenden von Padding auf alle Elemente unserer Daten auf die maximale Länge nicht effizient ist: Es ist besser, die Proben aufzufüllen, wenn wir ein Batch erstellen, da wir dann nur auf die maximale Länge in diesem Batch auffüllen müssen und nicht auf die maximale Länge in den gesamten Datensatz. Dies kann viel Zeit und Rechenleistung sparen, besonders wenn die Eingaben stark variable Längen haben!

So wenden wir die Tokenisierungsfunktion auf alle unsere Datensätze gleichzeitig an. In unserem Aufruf von `map` verwenden wir `batched=True`, damit die Funktion auf mehrere Elemente des Datensatzes gleichzeitig angewendet wird und nicht auf jedes Element separat. Dies ermöglicht eine schnellere Vorverarbeitung.

    tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
    tokenized_datasets

Die Bibliothek 🤗 Datasets verarbeitet Datensätzen indem sie neue Felder hinzuzufügen, eines für jeden Schlüssel im Dictionary, der von der Vorverarbeitungsfunktion zurückgegeben wird:

    DatasetDict({
        train: Dataset({
            features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
            num_rows: 3668
        })
        validation: Dataset({
            features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
            num_rows: 408
        })
        test: Dataset({
            features: ['attention_mask', 'idx', 'input_ids', 'label', 'sentence1', 'sentence2', 'token_type_ids'],
            num_rows: 1725
        })
    })

Du kannst sogar Multiprocessing verwenden, wenn du die Vorverarbeitungsfunktion mit `map()` anwendest, indem du ein `num_proc`-Argument übergiebst. Wir haben dies hier nicht getan, weil die 🤗 Tokenizers-Bibliothek bereits mehrere Threads verwendet, um unsere Samples schneller zu tokenisieren. Wenn du keinen schnellen Tokenizer verwendest, der von dieser Bibliothek unterstützt wird, würde dies allerdings die Vorverarbeitung beschleunigen.

Unsere `tokenize_function` gibt ein Dictionary mit den Schlüsselwerten `input_ids`, `attention_mask` und `token_type_ids` zurück, also werden diese drei Felder zu allen Splits unseres Datensatzes hinzugefügt. Beachte, dass wir auch vorhandene Felder ändern könnten, wenn unsere Vorverarbeitungsfunktion einen neuen Wert für einen vorhandenen Schlüsselwert in dem Datensatz zurückgegeben hätte, auf den wir `map()` angewendet haben.

Zuletzt, müssen wir alle Beispiele auf die Länge des längsten Elements aufzufüllen, wenn wir Elemente zusammenfassen – eine Technik, die wir als Dynamisches Padding bezeichnen.

###
Dynamisches Padding

Die Funktion, die für das Zusammenstellen von Samples innerhalb eines Batches verantwortlich ist, wird als Collate-Funktion bezeichnet. Es ist ein Argument, das du übergeben kannst, wenn du einen `DataLoader` baust, wobei es standardmäßig eine Funktion ist, die die Daten in PyTorch-Tensoren umwandelt und zusammenfügt (rekursiv wenn die Elemente Listen, Tupel oder Dictionaries sind). Dies ist in unserem Fall nicht möglich, da die Inputs nicht alle gleich groß sind. Das Padding haben wir bewusst aufgeschoben, um es bei jedem Batch nur bei Bedarf anzuwenden und überlange Inputs mit massivem Padding zu vermeiden. Dies beschleunigt das Training zwar, aber beachte, dass das Training auf einer TPU Probleme verursachen kann – TPUs bevorzugen feste Formen, auch wenn das ein zusätzliches Padding erfordert.

In der Praxis müssen wir eine Collate-Funktion definieren, die die korrekte Menge an Padding auf die Elemente des Datensatzes anwendet, die wir in einem Batch haben möchten. Glücklicherweise stellt uns die 🤗 Transformers-Bibliothek über `DataCollatorWithPadding` eine solche Funktion zur Verfügung. Wenn sie instanziert wird, braucht es einen Tokenizer (um zu wissen, welches Padding-token verwendet werden soll und ob das Modell erwartet, dass sich das Padding links oder rechts von den Inputs befindet) und übernimmt alles was wir brauchen:

    from transformers import DataCollatorWithPadding

    data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

Um dieses neue Werkzeug zu testen, nehmen wir einige Elemente aus den Trainingsdaten, die wir als Batch verwenden möchten. Hier entfernen wir die Spalten `idx`, `sentence1` und `sentence2`, da sie nicht benötigt werden und Strings enthalten (wir können keine Tensoren mit Strings erstellen) und sehen uns die Länge jedes Eintrags im Batch an:

    samples = tokenized_datasets["train"][:8]
    samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
    [len(x) for x in samples["input_ids"]]

    [50, 59, 47, 67, 59, 50, 62, 32]

Wenig überraschen erhalten wir Samples unterschiedlicher Länge von 32 bis 67. Dynamisches Padding bedeutet, dass die Elemente in diesem Batch alle auf eine Länge von 67 aufgefüllt werden, die maximale Länge innerhalb des Batches. Ohne dynamisches Auffüllen müssten alle Einträge auf die maximale Länge im gesamten Datensatz oder auf die maximale Länge die das Modell akzeptiert, aufgefüllt werden. Lass uns noch einmal überprüfen, ob unser `data_collator` den Stapel dynamisch richtig auffüllt:

    batch = data_collator(samples)
    {k: v.shape for k, v in batch.items()}

    {'attention_mask': torch.Size([8, 67]),
     'input_ids': torch.Size([8, 67]),
     'token_type_ids': torch.Size([8, 67]),
     'labels': torch.Size([8])}

Das sieht gut aus! Jetzt, da wir vom Rohtext zu Batches übergegangen sind, mit denen unser Modell umgehen kann, sind wir bereit zum fein-tunen!

✏️ Probier es aus! Repliziere die Vorverarbeitung auf dem GLUE SST-2-Datensatz. Es ist ein bisschen anders, da es aus einzelnen Sätzen statt aus Paaren besteht, aber der Rest von dem, was wir gemacht haben, sollte gleich aussehen. Alternative wäre eine schwierigere Herausforderung, eine Vorverarbeitungsfunktion zu schreiben, die bei allen GLUE-Aufgaben funktioniert.