KI Einführung in maschinelles Lernen mit Python und PyTorch

Aus TippvomTibb
Zur Navigation springen Zur Suche springen

Allgemeines

Ich habe hier für mich Informationen zusammengetragen, die mir das Implementieren von eigenen Neuronalen Netzen erlaubt.

Mein Ausgangspunkt sind Experimente mit meiner RTX3060 12GB. Diese laeuft parallel zu meiner Hauptgrafikkarte als reine "KI-Karte" um kein VRAM zu verschenken.

Ein erster Test mit OpenAI Whisper war schnell installiert und laeuft erstaunlich fluessig.

Jetzt ist es aber an der Zeit eigene Modelle zu programmieren und zu trainieren. Zu diesem Zweck habe ich mir ein vorlesungsaehnliches Script erzeugen lassen, das ich als Leitfaden nutze.

Skript

Einrichtung (Kapitel 1)

CopyBox

mkdir -p TorchSkript
cd TorchSkript/
source .venv/bin/activate
python -m pip install --upgrade pip
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu126
python -m pip install jupyterlab matplotlib. NumPy
python -m pip install jupyterlab matplotlib NumPy
python kurs.py --smoke
python kurs.py --model linear --epochs 1 --limit 2000 --out run_probe

MNIST

Nach der Installation existiert ein Verzeichnis /data/MNIST/raw.

In diesem Verzeichnis liegen die Trainingsgrafiken mit Ihren Labeln.

Die MNIST-Datenbank (Modified National Institute of Standards and Technology database[1]) ist eine öffentlich verfügbare Datenbank von handgeschriebenen Ziffern. Wobei jede Ziffer als 28 × 28 Pixel großes Graustufen-Bild gespeichert ist[1]. Die MNIST-Datenbank besteht aus 60.000 Beispielen im Trainingsdatensatz und 10.000 Beispielen im Testdatensatz. Der Datensatz dient dem Trainieren von Klassifikatoren, unter anderem Convolutional Neural Networks, im weiteren Sinne dem Maschinellen Lernen bzw. der Künstlichen Intelligenz.

-rw-r--r--. 1 chris users  7840016  6. Okt 20:28 t10k-images-idx3-ubyte
-rw-r--r--. 1 chris users  1648877  6. Okt 20:28 t10k-images-idx3-ubyte.gz
-rw-r--r--. 1 chris users    10008  6. Okt 20:28 t10k-labels-idx1-ubyte
-rw-r--r--. 1 chris users     4542  6. Okt 20:28 t10k-labels-idx1-ubyte.gz
-rw-r--r--. 1 chris users 47040016  6. Okt 20:28 train-images-idx3-ubyte
-rw-r--r--. 1 chris users  9912422  6. Okt 20:28 train-images-idx3-ubyte.gz
-rw-r--r--. 1 chris users    60008  6. Okt 20:28 train-labels-idx1-ubyte
-rw-r--r--. 1 chris users    28881  6. Okt 20:28 train-labels-idx1-ubyte.gz

Die .gz sind immer die gezippte Variante. IDX ist ein einfaches binaeres Format für mehrdimensionale Datenfelder, also Arrays oder Tensoren. Der Dateiname beschreibt den Inhalt: Bestandteil Bedeutung

  • idx1 Eindimensionales Datenfeld, beispielsweise eine Liste von Ziffernlabels
  • idx3 Dreidimensionales Datenfeld, beispielsweise viele zweidimensionale Bilder
  • ubyte Vorzeichenlose 8-Bit-Zahl: Werte von 0 bis 255, jeweils ein Byte

Bei MNIST sieht das konkret so aus: - train-images.idx3-ubyte enthält die Bilddaten mit der Form 60.000 × 28 × 28: Anzahl der Bilder × Höhe × Breite. Jeder Pixel hat einen Grauwert von 0 bis 255. - train-labels.idx1-ubyte enthält 60.000 Zahlen von 0 bis 9. Jede Zahl sagt, welche Ziffer auf dem zugehörigen Bild steht. Bild und Label sind über ihre Position zugeordnet: Zum ersten Bild gehört das erste Label, zum zweiten Bild das zweite Label usw. Am Dateianfang steht außerdem ein kleiner Header, der Datentyp, Anzahl der Dimensionen und deren Größen angibt. Danach folgen die eigentlichen Daten. Deshalb sind die Dateien in einem Texteditor nicht sinnvoll lesbar.

Mit dem IDX_Viewer kann man einen Blick in die Dateien werfen.

#!/usr/bin/env python3
"""IDX/MNIST-Viewer. Start: python3 idx_viewer.py [bilder [labels]]
Nur Python-Standardbibliothek und Tkinter nötig. Unterstützt auch .gz.
"""
import gzip
import math
import struct
import sys
from pathlib import Path
import tkinter as tk
from tkinter import ttk, filedialog, messagebox


def read_idx(path):
    opener = gzip.open if str(path).lower().endswith('.gz') else open
    with opener(path, 'rb') as f:
        header = f.read(4)
        if len(header) != 4 or header[:2] != b'\0\0' or header[2] != 8:
            raise ValueError('Keine IDX-Datei mit unsigned-byte-Daten.')
        dimensions = header[3]
        if dimensions not in (1, 3):
            raise ValueError('Unterstützt werden IDX1-Labels und IDX3-Bilder.')
        sizes = f.read(dimensions * 4)
        if len(sizes) != dimensions * 4:
            raise ValueError('Unvollständiger Header.')
        shape = struct.unpack('>' + 'I' * dimensions, sizes)
        if not all(shape):
            raise ValueError('Die Datei enthält eine leere Dimension.')
        data = f.read()
    if len(data) != math.prod(shape):
        raise ValueError(f'Datenlänge stimmt nicht: {len(data)} statt {math.prod(shape)} Bytes.')
    return shape, data


class Viewer:
    def __init__(self, root):
        self.root = root
        self.images = None
        self.labels = None
        self.index = 0
        root.title('IDX / MNIST Viewer')
        root.geometry('650x610')
        toolbar = ttk.Frame(root, padding=10)
        toolbar.pack(fill='x')
        ttk.Button(toolbar, text='Bilder öffnen …', command=self.open_images).pack(side='left')
        ttk.Button(toolbar, text='Labels öffnen …', command=self.open_labels).pack(side='left', padx=8)
        self.info = ttk.Label(root, text='Bitte eine idx3-ubyte-Datei öffnen.', wraplength=620)
        self.info.pack(padx=10, pady=5)
        self.canvas = tk.Canvas(root, background='#242424', highlightthickness=0)
        self.canvas.pack(fill='both', expand=True, padx=10, pady=10)
        self.canvas.bind('<Configure>', lambda e: self.render())
        nav = ttk.Frame(root, padding=10)
        nav.pack(fill='x')
        ttk.Button(nav, text='◀ Zurück', command=lambda: self.move(-1)).pack(side='left')
        ttk.Button(nav, text='Weiter ▶', command=lambda: self.move(1)).pack(side='left', padx=6)
        ttk.Label(nav, text='Bildnummer:').pack(side='left', padx=(12, 3))
        self.number = tk.StringVar(value='1')
        entry = ttk.Entry(nav, textvariable=self.number, width=9)
        entry.pack(side='left')
        entry.bind('<Return>', lambda e: self.jump())
        ttk.Button(nav, text='Gehe zu', command=self.jump).pack(side='left', padx=6)
        self.status = ttk.Label(root, text='Pfeiltasten: blättern · Bildnummern beginnen bei 1')
        self.status.pack(pady=(0, 10))
        root.bind('<Left>', lambda e: self.move(-1))
        root.bind('<Right>', lambda e: self.move(1))

    def choose(self, title):
        return filedialog.askopenfilename(title=title, filetypes=[('Alle Dateien', '*')])

    def open_images(self, path=None):
        path = path or self.choose('IDX3-Bilddatei auswählen')
        if not path:
            return
        try:
            shape, data = read_idx(path)
            if len(shape) != 3:
                raise ValueError('Bitte eine IDX3-Bilddatei auswählen.')
            self.shape, self.images, self.path = shape, data, path
            self.labels = None
            self.index = 0
            self.render()
            # Die übliche MNIST-Labeldatei automatisch finden.
            candidate = Path(path).with_name(Path(path).name.replace('images', 'labels').replace('idx3', 'idx1'))
            if candidate != Path(path) and candidate.is_file():
                self.open_labels(str(candidate))
        except (OSError, ValueError, EOFError) as exc:
            messagebox.showerror('Datei kann nicht geöffnet werden', str(exc))

    def open_labels(self, path=None):
        if self.images is None:
            messagebox.showinfo('Bilder fehlen', 'Bitte zuerst die Bilddatei öffnen.')
            return
        path = path or self.choose('IDX1-Labeldatei auswählen')
        if not path:
            return
        try:
            shape, data = read_idx(path)
            if len(shape) != 1 or shape[0] != self.shape[0]:
                raise ValueError('Die Labeldatei muss eindimensional sein und gleich viele Einträge wie die Bilddatei enthalten.')
            self.labels = data
            self.render()
        except (OSError, ValueError, EOFError) as exc:
            messagebox.showerror('Labels können nicht geöffnet werden', str(exc))

    def move(self, offset):
        if self.images is not None:
            self.index = min(max(0, self.index + offset), self.shape[0] - 1)
            self.render()

    def jump(self):
        if self.images is None:
            return
        try:
            number = int(self.number.get())
            if not 1 <= number <= self.shape[0]:
                raise ValueError
            self.index = number - 1
            self.render()
        except ValueError:
            messagebox.showerror('Ungültige Bildnummer', f'Bitte eine Zahl von 1 bis {self.shape[0]} eingeben.')

    def render(self):
        if self.images is None:
            return
        count, height, width = self.shape
        self.number.set(str(self.index + 1))
        label = str(self.labels[self.index]) if self.labels is not None else 'nicht geladen'
        self.info.config(text=f'{Path(self.path).name}\n{count:,} Bilder · {width} × {height} Pixel · Label: {label}')
        start = self.index * width * height
        pixels = self.images[start:start + width * height]
        # Pixel direkt setzen: funktioniert auch ohne PGM-Unterstützung.
        original = tk.PhotoImage(width=width, height=height)
        palette = [f'#{v:02x}{v:02x}{v:02x}' for v in range(256)]
        rows = ['{' + ' '.join(palette[v] for v in pixels[y * width:(y + 1) * width]) + '}'
                for y in range(height)]
        original.put(' '.join(rows))
        cw, ch = self.canvas.winfo_width(), self.canvas.winfo_height()
        scale = max(1, min(max(1, cw - 20) // width, max(1, ch - 20) // height))
        self.photo = original.zoom(scale, scale)
        self.canvas.delete('all')
        self.canvas.create_image(cw // 2, ch // 2, image=self.photo, anchor='center')
        self.status.config(text=f'Bild {self.index + 1} / {count} · Vergrößerung {scale}× · ← / → zum Blättern')


def main():
    root = tk.Tk()
    viewer = Viewer(root)
    if len(sys.argv) > 1:
        viewer.open_images(sys.argv[1])
    if len(sys.argv) > 2:
        viewer.open_labels(sys.argv[2])
    root.mainloop()


if __name__ == '__main__':
    main()

Konzentrat

Lernziele:

  • Klassifikationsaufgabe formulieren
  • Tensorformen erklären
  • einen Datenfluss implementieren
  • Gradienten und Training nachvollziehen
  • geeignete Metriken berechnen sowie ein Modell speichern und für neue Eingaben benutzen
  • außerdem Datenleckage, Überanpassung und irreführende Aussagen über Modellwahrscheinlichkeiten erkennen.

Baseline:

Eine Baseline ist ein einfacher Referenzwert oder eine Ausgangslösung, mit der du spätere Ergebnisse vergleichst.

Beim Trainieren eines KI-Modells für die MNIST-Ziffernerkennung könnten Baselines sein:

Baseline Zweck
Zufällig eine Ziffer von 0 bis 9 wählen Im Durchschnitt etwa **10 % Trefferquote**
Immer die häufigste Ziffer vorhersagen Zeigt, was ohne Auswertung der Bilder erreichbar ist
Ein einfaches Modell trainieren Liefert einen praktischen Vergleich für komplexere Modelle

Beispiel: Dein einfaches Modell erreicht 92 % richtige Zuordnungen. Diese 92 % dienen als Baseline. Ein aufwendigeres Modell erreicht anschließend 97 % — eine Verbesserung um 5 Prozentpunkte.

Die Baseline hilft also bei der Frage: Bringt meine neue Methode tatsächlich einen Vorteil? Dafür müssen beide Modelle mit denselben Testdaten bewertet werden.

CheckEnviroment

import sys

def check_environment():
    print('Python:', sys.version)
    print('Python:', sys.version.split()[0])
    print('Python-Interpreter:', sys.executable)
    print('Virtuelle Umgebung aktiv:', sys.prefix != sys.base_prefix)
    print('Umgebungspfad:', sys.prefix)
    try:
        import torch
    except ImportError:
        print('PyTorch: nicht installiert oder nicht importierbar.')
        return
    print('PyTorch:', torch.__version__)
    print('CUDA-Version des PyTorch-Builds:', torch.version.cuda)
    if torch.cuda.is_available():
        device = torch.device('cuda:0')
        print('Verfügbare CUDA-Geräte:', torch.cuda.device_count())
        for index in range(torch.cuda.device_count()):
            print(f'  cuda:{index}:', torch.cuda.get_device_name(index))
    elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
        device = torch.device('mps') # MPS Backend (Metal Performance Shaders) Apple Silicon GPU
    else:
        device = torch.device('cpu')
    print('Gewähltes Gerät:', device)
    # Kleine Rechenprobe auf dem ausgewählten Gerät.
    result = (torch.tensor([1.0, 2.0], device=device) * 2).cpu().tolist()
    print('PyTorch-Rechenprobe:', result)

if __name__ == '__main__':
    check_environment()

Anmerkung:

.cpu().tolist() wandelt einen PyTorch-Tensor in eine normale Python-Liste um. Das passiert in zwei Schritten:

Aufruf Bedeutung
.cpu() Überträgt den Tensor in den Arbeitsspeicher der CPU, falls er auf der GPU liegt. Liegt er bereits auf der CPU, bleibt er dort.
.tolist() Wandelt die Tensorwerte in Python-Zahlen und eine Python-Liste um. Bei mehrdimensionalen Tensoren entstehen verschachtelte Listen.

Das Ergebnis enthält dieselben Zahlen, ist aber kein Tensor mehr. Übrigens: .tolist() überträgt einen GPU-Tensor automatisch zur CPU. Hier würde deshalb auch allein .tolist() genügen; .cpu() macht den Transfer nur ausdrücklich sichtbar.

Vokabel

  • Zielklasse:
  • Trainingsphase:
  • Infernezphase:
  • Qualitaetsmasz:
  • Konfusionsmatrix: Eine Konfusionsmatrix (Confusion Matrix) zeigt, welche Klassen ein Klassifikationsmodell richtig erkennt und welche es miteinander verwechselt. Hauptdiagonale=richtige Klassifikation alle anderen Angaben sind Fehlklassifiaktionen.
  • Accuracy: Die Accuracy (Trefferquote) gibt an, welcher Anteil aller Vorhersagen korrekt war. Bei einer Konfusionsmatrix kannst du die Accuracy ebenfalls direkt bestimmen:

$ Accuracy={\frac {SummederHauptdiagonale}{SummeallerEintraege}} $

Merke:

Die Accuracy sagt: „Wie oft lag das Modell richtig?“
Die Konfusionsmatrix sagt zusätzlich: „Was wurde womit verwechselt?“
  • Mehrheitsklassen-Baseline:
  • Taxonomie: Die Taxonomie ist nicht Bestandteil des neuronalen Netzes an sich. Sie beschreibt die fachliche Struktur der Klassen. Das neuronale Netz ist das Verfahren, mit dem die Zuordnung zu diesen Klassen gelernt werden kann.

Merke:

Taxonomie = Welche Klassen gibt es?
Klassifikation = Zu welcher Klasse gehört eine Eingabe?
Neuronales Netz = Wie kann diese Zuordnung aus Beispielen gelernt werden?

Ueberblick

Begriff Bedeutung Beispiel Ziffernerkennung
Klasse Eine mögliche Kategorie „7“
Zielklasse Die Klasse, der ein konkretes Beispiel tatsächlich zugeordnet werden soll Das Bild zeigt tatsächlich eine 7
Label Technische Repräsentation der Zielklasse in den Daten `7`
Taxonomie Festgelegtes System aller Klassen und ggf. ihrer Beziehungen `{0,1,2,...,9}`
Klassifikation Die Aufgabe bzw. der Vorgang, eine Eingabe einer Klasse zuzuordnen Bild → „7“
Klassifikator Das System/Modell, das die Klassifikation durchführt neuronales Netz
Vorhersage / Prediction Die vom Modell bestimmte Klasse Modell sagt: „7“
Konfidenz / Score Wie stark das Modell eine Klasse bevorzugt „7“: 92 %
Ground Truth Die als korrekt angenommene Zuordnung tatsächliches Label = `7`
Accuracy Anteil aller korrekten Klassifikationen 94 % richtig
Konfusionsmatrix Zeigt, welche Klassen miteinander verwechselt werden 7 wird oft als 1 erkannt

Musterloesung

Ich fand die Aufgabenstellung im Skript suboptimal. Entweder man setzt einen engeren Rahmen der Erwartung, oder zerteilt sie in Teilaufgaben. Die Fragestellung ist didaktisch unklug.

Zur Erinnerung:

try:
    # Code that may raise an exception
except:
    # Runs if an exception occurs
else:
    # Runs if no exception occurs
finally:
    # Always executed

TODO

Tensoren (Kapitel 2)

NCHW is an acronym describing the order of the axes in a tensor containing image data samples.

N: Number of data samples.

C: Image channels. A red-green-blue (RGB) image will have 3 channels.

H: Image height.

W: Image width.

Debuggen

print(f"{x=}, type={type(x)}") Das kommt dem Zweck von var_dump() wie in PHP schon recht nahe.

Skript Seite 5:

import torch  # Importiert die Bibliothek PyTorch unter dem Namen "torch".

# ------------------------------------------------------------
# 1. Einen Batch (Charge/Stapel) mit vier zufälligen Bildern erzeugen
# ------------------------------------------------------------

# Erzeugt einen Tensor mit Zufallszahlen zwischen 0 und 1. [0,1) (0 inclusive, 1 exclusive)
# Die Dimensionen bedeuten:
# 4  = Anzahl der Bilder im Batch
# 1  = Anzahl der Kanäle (Graustufenbild)
# 28 = Höhe des Bildes
# 28 = Breite des Bildes
# Der Tensor hat also 4 x 1 x 28 x 28 = 3136 Elemente bzw. Postionen.
# Die einzelnen Elemente (Spalten) und Zeilen werden jeweils durch Komma getrennt.
# Die Tensorausgabe liest man am besten rueckwaerts. Die innerste eckige Klammer ist die Representation des letzten Parameters im tensor-Befehl, usw. 
# Tensoren werden hier als eine verschachtelte Liste gefuehrt.    
x = torch.rand(4, 1, 28, 28)

# Gibt den kompletten Tensor sowie seinen Python-Typ aus.
print(f"{x=}, type={type(x)}")

# Für Tensoren sind zusätzlich Shape, Datentyp und Gerät interessant.
print(f"{x.shape=}, type={type(x.shape)}")  # Form/Dimensionen
print(f"{x.ndim=}, type={type(x.ndim)}")   # Anzahl der Dimensionen
print(f"{x.numel()=}, type={type(x.numel())}") # Anzahl aller Elemente
print(f"{x.dtype=}, type={type(x.dtype)}")
print(f"{x.device=}, type={type(x.device)}")


# ------------------------------------------------------------
# 2. Die zugehörigen Zielklassen erzeugen
# ------------------------------------------------------------

# Für die vier Bilder werden vier Zielklassen angegeben.
# Bild 0 gehört zur Klasse 2.
# Bild 1 gehört zur Klasse 7.
# Bild 2 gehört zur Klasse 0.
# Bild 3 gehört zur Klasse 9.
#
# dtype=torch.long erzeugt ganzzahlige 64-Bit-Werte.
# Dieser Datentyp wird z. B. von CrossEntropyLoss für Klassenlabels erwartet.
y = torch.tensor([2, 7, 0, 9], dtype=torch.long)

print(f"{y=}, type={type(y)}")
print(f"{y.shape=}, type={type(y.shape)}")
print(f"{y.dtype=}, type={type(y.dtype)}")
print(f"{y.device=}, type={type(y.device)}")


# ------------------------------------------------------------
# 3. Überprüfen der Tensorformen
# ------------------------------------------------------------

# Prüft, ob x tatsächlich die erwartete Form besitzt.
# Falls die Bedingung falsch ist, wird ein AssertionError ausgelöst.
assert x.shape == (4, 1, 28, 28)

# Wir geben die geprüfte Bedingung zusätzlich aus.
print(f"{x.shape == (4, 1, 28, 28)=}, type={type(x.shape == (4, 1, 28, 28))}")


# Prüft, ob y genau vier Zielklassen enthält.
assert y.shape == (4,)

print(f"{y.shape == (4,)=}, type={type(y.shape == (4,))}")


# ------------------------------------------------------------
# 4. Bilder in Vektoren umwandeln
# ------------------------------------------------------------

# flatten(start_dim=1) lässt Dimension 0 unverändert.
#
# Vorher:
# (4, 1, 28, 28)
#
# Die Dimensionen ab Dimension 1 werden zusammengefasst:
#
# 1 * 28 * 28 = 784
#
# Ergebnis:
# (4, 784) Also 784 Werte in einer Zeile [x11, x12, x13, ....x1784] und das viermal.
#
# Wir haben danach also weiterhin vier Beispiele,
# aber jedes Bild wird als Vektor mit 784 Zahlen dargestellt.
flat = x.flatten(start_dim=1)

print(f"{flat=}, type={type(flat)}")
print(f"{flat.shape=}, type={type(flat.shape)}")


# Prüft die erwartete Form.
assert flat.shape == (4, 784)

print(f"{flat.shape == (4, 784)=}, type={type(flat.shape == (4, 784))}")


# ------------------------------------------------------------
# 5. Ein einzelnes Bild aus dem Batch auswählen
# ------------------------------------------------------------

# x[0] wählt das erste Bild aus.
#
# x hatte die Form:
#
# (4, 1, 28, 28)
#  ^
#  diese Dimension wird durch [0] entfernt ????
# Was dieser Hinweis von ChatGPT zu bedeuten hat weisz ich nicht.
# Vielmehr wird mit single = x[0] der erste Eintrag des Tensors mit 784 Werten mit dem Shape (1,28,28) der Variablen single zugewiesen.
# besser erkennbar durch:
# first = x[0]
# second = x[1]
# third = x[2]
# fourth = x[3]
#
# Deshalb hat single nur noch:
#
# (1, 28, 28)
single = x[0]

print(f"{single=}, type={type(single)}")
print(f"{single.shape=}, type={type(single.shape)}")

# Kontrolle:
assert single.shape == (1, 28, 28)

print(f"{single.shape == (1, 28, 28)=}")


# ------------------------------------------------------------
# 6. Einen Batch erzeugen, der nur ein Bild enthält
# ------------------------------------------------------------

# x[0:1] ist ein sogenannter Slice.
#
# Im Gegensatz zu x[0] wird die erste Dimension NICHT entfernt.
#
# Aus
#
# (4, 1, 28, 28)
#
# wird deshalb:
#
# (1, 1, 28, 28)
#
# Das bedeutet:
# Wir haben weiterhin einen Batch, aber dieser enthält nur ein Bild.
# Slicing Syntax [Start:Stopp]. Der Start-Index ist dabei inklusive, der Stopp-Index exklusive (wird nicht mehr mitgezählt). Ein fehlender Start/Stopp-Wert bedeutet Anfang, bzw. Ende. 

batch_of_one = x[0:1]

print(f"{batch_of_one=}, type={type(batch_of_one)}")
print(f"{batch_of_one.shape=}, type={type(batch_of_one.shape)}")

# Kontrolle:
assert batch_of_one.shape == (1, 1, 28, 28)

print(f"{batch_of_one.shape == (1, 1, 28, 28)=}")


# ------------------------------------------------------------
# 7. Abschließende Zusammenfassung
# ------------------------------------------------------------

print("\n--- Zusammenfassung ---")

print(f"{x.shape=}")
print(f"{y.shape=}")
print(f"{flat.shape=}")
print(f"{single.shape=}")
print(f"{batch_of_one.shape=}")

CrosEntropyLoss

CrossEntropyLoss ist eine sehr haeufig verwendete Verlustfunktion (Loss Function) fuer Klassifikationsprobleme in PyTorch.

Sie beantwortet während des Trainings im Wesentlichen die Frage:

„Wie stark weicht die Vorhersage des neuronalen Netzes von der richtigen Zielklasse ab?“

Nehmen wir deinen Ziffernklassifikator. Die Taxonomie bzw. Klassenmenge besteht aus:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

Für ein Bild ist die Zielklasse beispielsweise:

y = 7

Das neuronale Netz liefert aber nicht einfach 7, sondern zunächst 10 Ausgabewerte, sogenannte Logits:

Klasse     Logit
  0         0.2
  1        -0.5
  2         0.8
  3         0.1
  4        -0.3
  5         0.4
  6         0.2
  7         3.8   ←
  8         0.5
  9         1.1

Je größer der Logit, desto stärker spricht das Modell für diese Klasse.

Was macht CrossEntropyLoss daraus?

Vereinfacht passieren zwei Dinge:

$ {\text{Logits}}\xrightarrow {\text{Softmax}} {\text{Wahrscheinlichkeiten}}\xrightarrow {\text{Vergleich mit Zielklasse}} {\text{Loss}} $

CrossEntropyLoss kombiniert diese Berechnungen intern. Deshalb solltest du vor CrossEntropyLoss normalerweise kein eigenes softmax() aufrufen.

In PyTorch sieht das beispielsweise so aus:

import torch
import torch.nn as nn

# Ausgabe des neuronalen Netzes:
# 4 Bilder und jeweils 10 Klassen
logits = torch.rand(4, 10)

# Richtige Zielklassen der vier Bilder
y = torch.tensor([2, 7, 0, 9], dtype=torch.long)

# Verlustfunktion erzeugen
loss_fn = nn.CrossEntropyLoss()

# Verlust berechnen
loss = loss_fn(logits, y)

print(f"{logits.shape=}")
print(f"{y.shape=}")
print(f"{loss=}")

Dabei ist entscheidend:

logits.shape = (4, 10)
                │   │
                │   └── 10 mögliche Klassen
                └────── 4 Bilder

y.shape = (4,)
           │
           └── eine richtige Zielklasse pro Bild

Das passt genau zu deinem bisherigen Beispiel:

Bild x[0] → Zielklasse 2
Bild x[1] → Zielklasse 7
Bild x[2] → Zielklasse 0
Bild x[3] → Zielklasse 9

Warum braucht man überhaupt einen Loss?

Beim Training läuft vereinfacht diese Kette ab:

Trainingsbild
     ↓
Neuronales Netz
     ↓
Logits
     ↓
CrossEntropyLoss ← richtige Zielklasse
     ↓
Loss
     ↓
Backpropagation
     ↓
Gewichte des Netzes verändern

Ein kleiner Loss bedeutet: Die Vorhersage passt gut zur Zielklasse.

Ein großer Loss bedeutet: Die Vorhersage passt schlecht zur Zielklasse.

Dabei berücksichtigt Cross Entropy nicht nur, ob die endgültige Klassifikation richtig oder falsch war, sondern auch, wie sicher das Modell dabei war. Eine selbstbewusste falsche Vorhersage wird stärker bestraft als eine unsichere falsche Vorhersage.

Das ist auch der entscheidende Unterschied zur Accuracy:

$ {\text{CrossEntropyLoss → zum Trainieren}} $

$ {\text{Accuracy → zum Bewerten}} $


Die Accuracy ist nämlich nicht gut geeignet, um mittels Backpropagation die Gewichte eines neuronalen Netzes schrittweise zu verbessern.

Daten (Kapitel 3)

Dataset: Ein Paar aus Eingabe und Ziel DataLoader: bündelt Datasets zu Batches

Aufteilen des Datenbestandes:

  • Trainingsbestand (groeszter Anteil) bestimmt Parameter
  • Validierungsbestand hilft bei Architektur, Lernrate und Epochenzahl
  • Testbestand

TorchVsion

Torchvision ist eine offizielle, spezialisierte Erweiterungs-Bibliothek für das Deep-Learning-Framework PyTorch, die speziell für Aufgaben im Bereich des maschinellen Sehens (Computer Vision) entwickelt wurde.

Während PyTorch die grundlegenden mathematischen Funktionen und Tensoren bereitstellt, liefert Torchvision die passenden Werkzeuge, um direkt mit Bildern und Videos zu arbeiten.

Kurzschreibweisen

from torch.utils.data import random_split, DataLoader
import torch

Dadurch lassen sich random_split und Dataloader direkt aufrufen.

Die Funktion torch.utils.data.random_split wird in PyTorch verwendet, um einen Datensatz (ein Dataset-Objekt) zufällig in mehrere kleinere, sich nicht überschneidende Teildatensätze aufzuteilen.

torch.utils.data.random_split(dataset, lengths, generator=None)
  • dataset: Das ursprüngliche Datensatz-Objekt (z. B. geladen via torchvision.datasets).
  • lengths: Eine Liste, die angibt, wie groß die einzelnen Teile sein sollen. Das können entweder absolute Zahlen (Anzahl der Bilder/Zeilen) oder Prozentwerte (als Float zwischen 0 und 1) sein.
  • generator: Ein optionaler Zufallsgenerator (torch.Generator()). Extrem wichtig für die Reproduzierbarkeit deines Codes.

Das Modul torchvision.datasets ist die eingebaute Schatzkiste von PyTorch für Bilddaten. Es enthält eine riesige Auswahl an vorgefertigten, populären Datensätzen für das maschinelle Sehen, die du mit nur einer Zeile Code herunterladen, formatieren und direkt in dein neuronale Netzwerk einspeisen kannst.

assert x.shape[1:] == (1, 28, 28) , f"Falsche Shape: {x.shape}"

Das Schlüsselwort assert (englisch für „behaupten“ oder „versichern“) wird in Python verwendet, um Bedingungen im Code zu überprüfen. Es dient als internes Sicherheitsnetz während der Entwicklung.

Einfacher empfinde sich das Schluesselwort "teste" fuer assert einzusetzen. Bei wahr geht's weiter und bei falsch wird die optionale (Fehler-)Meldung ausgegeben und der Programmablauf abgebrochen.

Mit dem Aufruf python -O mein_skript.py ignoriert Python alle assert-Zeilen komplett. Das spart im fertigen Produkt Rechenzeit. Eine if-Abfrage hingegen wird immer ausgefuehrt, falls man auf die Idee kaeme die Kontrolle mit einer if-Abfrage zu realisieren.

Uebung 3

Aufgabe 1

    print("--- 1. MNIST laden und 6 Bilder anzeigen ---")
    # Transformation: PIL-Bild in Fließkomma-Tensor (0 bis 1) umwandeln
    transform = transforms.ToTensor()

    # Gesamten Trainingsbestand laden (60.000 Beispiele)
    full_dataset = datasets.MNIST('data', train=True, download=True, transform=transform)

    # 6 Beispielbilder plotten (optional, bricht das Skript nicht ab)
    fig, axes = plt.subplots(1, 6, figsize=(12, 3))
    for i in range(6):
        image, label = full_dataset[i]
        # squeeze() entfernt die Kanalachse (1, 28, 28) -> (28, 28) für imshow
        axes[i].imshow(image.squeeze(), cmap='gray', vmin=0, vmax=1)
        axes[i].set_title(f"Label: {label}")
        axes[i].axis('off')
    plt.show(block=False) # block=False, damit das Skript weiterläuft
    plt.pause(2) # Zeigt das Bild für 2 Sekunden
    plt.close()

Aufgabe 2

   print("\n--- 2. Reproduzierbaren Daten-Split erstellen ---")
    # Trennung in 55.000 Training und 5.000 Validierung mit festem Seed 2026
    train_set, valid_set = random_split(
        full_dataset,
        [55000, 5000],
        generator=torch.Generator().manual_seed(2026)
    )

    # Größen und Überschneidungsfreiheit durch Assertions prüfen
    assert len(train_set) == 55000
    assert len(valid_set) == 5000
    assert set(train_set.indices).isdisjoint(set(valid_set.indices))
    print("✓ Größenprüfung erfolgreich.")
    print("✓ Die Indizes der Teilmengen sind absolut überschneidungsfrei.")

Aufgabe 3

    print("\n--- 3. Klassenhäufigkeiten und Baseline bestimmen ---")
    # Alle Labels aus dem Trainingsset extrahieren
    train_labels = [full_dataset[i][1] for i in train_set.indices]
    counts = Counter(train_labels)
    print("Häufigkeiten im Training (Ziffer: Anzahl):", dict(sorted(counts.items())))

    # Häufigste Klasse ermitteln
    frequentest_class, freq_count = counts.most_common(1)[0]
    print(f"Die häufigste Ziffer im Trainingsset ist: '{frequentest_class}' (kommt {freq_count}x vor)")

    # Baseline auf dem Validierungsset auswerten
    valid_labels = [full_dataset[i][1] for i in valid_set.indices]
    correct_predictions = sum(1 for label in valid_labels if label == frequentest_class)
    baseline_accuracy = correct_predictions / len(valid_set)
    print(f"Triviale Baseline-Accuracy auf der Validierung: {baseline_accuracy * 100:.2f}%")

Aufgabe 4

   print("\n--- 4. DataLoader & Shuffle-Vergleich ---")
    # DataLoader aufbauen (Training MUSS gemischt werden, Validierung NICHT)
    train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=0)
    valid_loader = DataLoader(valid_set, batch_size=64, shuffle=False, num_workers=0)

    print(f"Anzahl der Trainings-Batches (55000 / 64 ohne drop_last): {len(train_loader)}")
    print(f"Anzahl der Validierungs-Batches (5000 / 64 ohne drop_last): {len(valid_loader)}")

    # Wertebereich prüfen am ersten Batch
    x_batch, y_batch = next(iter(train_loader))
    assert x_batch.shape[1:] == (1, 28, 28)
    assert y_batch.dtype == torch.long
    assert 0 <= x_batch.min() and x_batch.max() <= 1
    print("✓ Formen und Wertebereiche im ersten Batch erfolgreich validiert.")

Request for Comments

Loading comments...