Maschinelles Lernen

In diesem Teil soll es um ein einfaches Beispiel für maschinelles Lernen gehen. Maschinelles Lernen bedeutet, dass ohne menschliches Zutun neues Wissen aus bestehenden Daten erhalten und dieses verallgemeinert wird. Dadurch sollen Zusammenhänge und Muster erkannt werden, die auch auf unbekannte Beispieledaten anwendbar sind.

Ein paar Beispiele, wo maschinelles Lernen sehr praktisch sein kann:

  • Schrifterkennung

  • Texte übersetzen

  • Spotify / YouTube Vorschläge

  • Aktivitätserkennung

  • Werbung filtern

  • Facebook Timeline

Fallen euch noch mehr ein?

Was ist ein neuronales Netz?

Herkömmliche neuronale Netze, wie sie beim maschinellen Lernen verwendet werden, sind aus mehreren Lagen aus Neuronen aufgebaut (orange), welche unterschiedlich stark verbunden sein können. Diese Stärke dieser Verbindungen, die Gewichte, enthalten das “Wissen” und werden beim Trainieren automatisch angepasst.

../_images/girlsday_perceptron.png

Wie viele Neuronen und Gewichte sind nötig?

Die Wahl eines geeigneten Modells ist gar nicht so einfach. Ist das Modell zu klein, also enthält es zu wenig Neuronen und Gewichte, dann kann es die zu lernenden Zusammenhänge nicht richtig abbilden. Ist es aber zu komplex, also enthält es zu viele Neuronen, so ist dies ebenfalls schlecht. Es können dann zwar die meisten Beispiele, die zum Lernen verwendet wurden, richtig erkannt werden, bei Unbekanntem scheitert das Modell aber. Man sagt, dass solch ein Modell nicht genug verallgemeinern kann. Es hat zwar alle Beispiele auswendig gelernt, kann aber die zu Grunde liegenden Zusammenhänge nicht verstehen.

../_images/girlsday_overfitting.png

Umgebung vorbereiten

Bevor wir mit unseren Experimenten beginnen können, müssen wir erneut unsere Umgebung vorbereiten:

from _static.common.helpers import setup_hardware_client, save_nightly_calibration
setup_hardware_client()

Beispiel: Erkennung von handgeschriebenen Ziffern

Dies ist ein sehr beliebtes Beispiel für den Einsatz von maschinellem Lernen: In einem Briefzentrum sollen die Postleitzahlen der Briefe in der Sortieranlage automatisch gelesen werden, damit die Briefe in die richtigen Fahrzeuge verladen werden können. Um dieses Problem zu lösen, wurden viele verschiedene Bilder gesammelt und jeweils der richtigen Ziffer zugeordnet.

# Hier werden erstmal alle Dinge importiert, die wir später brauchen.
import threading
import traceback
import time
from functools import partial
import numpy as np
import torch
from torchvision import datasets, transforms
import hxtorch
import ipywidgets as w
from ipycanvas import Canvas, hold_canvas
from IPython import get_ipython
from tqdm.auto import tqdm

%matplotlib inline
import matplotlib.pyplot as plt

save_nightly_calibration('hagen_cocolist.pbin')
hxtorch.init_hardware(hxtorch.CalibrationPath('hagen_cocolist.pbin'))
hxtorch.perceptron.set_mock_parameter(hxtorch.perceptron.measure_mock_parameter())

Im maschinellen Lernen ist das klassifizieren von Bildern ein beliebtes Beispiel, sodass eine große Sammlung von Bildern, welche Zahlen darstellen, frei verfügbar ist. Diese Sammlung laden wir im folgenden runter:

# Ort, an dem das Set von Bildern gespeichert werden soll:
data_path = 'mnist'
# use stored MNIST data for tests
data_path = '/loh/data/mnist'
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Lambda(lambda x: x.round())
])
train_transform = transforms.Compose([
    transforms.RandomResizedCrop(28),
    transform,
])

# Die Bilder der Ziffern werden geladen.
# Mit `train_data` wird trainiert, mit `test_data` kann nachher überprüft
# werden, wie gut das Netzwerk verallgemeinern kann.
train_data = datasets.MNIST(data_path, train=True, transform=train_transform,
                            download=True)
test_data = datasets.MNIST(data_path, train=False, transform=transform,
                           download=True)

numbers = {n: [] for n in range(10)}
for img, n in test_data:
    numbers[n].append(img)

train_loader = torch.utils.data.DataLoader(
    dataset=train_data, batch_size=200, shuffle=True)
test_loader = torch.utils.data.DataLoader(
    dataset=test_data, batch_size=200)

Nun stellen wir einige, zufällige Bilder aus dem Set dar:

fig, axs = plt.subplots(2, 2, constrained_layout=True)
N = len(test_data)
for ax in axs.flatten():
    image = test_data[np.random.randint(0, N-1)][0][0]
    ax.imshow(image, cmap='Greys')
    ax.set_axis_off()
../_images/girlsday_mnist_output1.png

Um diese Bilder jetzt auch automatisch richtig zu erkennen, wird zunächst eine Beschreibung eines neuronalen Netzwerks benötigt. Der folgende Programmcode definiert ein Netzwerk mit einer einzelnen verstecken Lage mit 128 Neuronen.

class Model(torch.nn.Module):
    """
    Ein sehr einfaches neuronales Netzwerk mit einer einzigen
    Lage aus versteckten Neuronen.
    """
    def __init__(self, mock=True):
        super().__init__()
        self.mock = mock
        num_hidden = 256  # die Anzahl der versteckten Neuronen
        self.classifier = torch.nn.Sequential(
            # Diese Lage verbindet jeden Pixel des Bildes
            # mit jedem versteckten Neuron:
            hxtorch.perceptron.nn.Linear(
                in_features=28 * 28,  # die Bilder sind 28x28 Pixel groß
                out_features=num_hidden,
                mock=mock), hxtorch.perceptron.nn.ConvertingReLU(shift=1, mock=True),
            hxtorch.perceptron.nn.Linear(
                in_features=num_hidden,
                out_features=num_hidden,
                mock=mock), hxtorch.perceptron.nn.ConvertingReLU(shift=1, mock=True),
            # Diese Lage verbindet jedes der verstecken Neuronen
            # mit einem der 10 möglichen Ausgänge:
            hxtorch.perceptron.nn.Linear(
                in_features=num_hidden,
                out_features=10,  # es gibt 10 verschiedene Ziffern
                mock=mock)
        )

    def forward(self, x):
        x = x.view(-1, 28 * 28) * 31.
        x = self.classifier(x)
        return x

Dieses Modell ist aber noch “dumm”, d.h. es kann die Zahlen noch nicht richtig erkennen. Das können wir direkt mal ausprobieren:

class ZahlenMalen:
    """
    Einfache Oberfläche, um MNIST-Bilder zu malen.
    """

    def __init__(self, model, numbers, *,
                 scale: int = 10, line_width: float = 2.5, autostop: bool = True):
        self.down = False
        self.changes_pending = True
        self.running = False
        self.model = model
        self.numbers = numbers
        self.scale = scale
        self.line_width = line_width
        self.autostop = autostop
        self.started_at = get_ipython().execution_count

        # Elemente der Oberfläche vorbereiten
        self.canvas = Canvas(
            width=scale * 28, height=scale * 28,
            sync_image_data=True,
            layout=w.Layout(border='solid gray', margin='10px'))

        # Wir verwenden "gray", da "black" nur die Transparenz auf 0 setzt,
        # wir aber aktiv die RGB Werte setzen wollen.
        self.canvas.fill_style = "gray"
        self.clear()

        self.color_chooser = w.RadioButtons(
            description='Farbe ✏️', options=['schwarz', 'weiß'])
        self.clear_button = w.Button(description='Löschen', button_style='')
        self.submit_button = w.Button()
        self.result_text = w.HTML()
        height = (scale * 28 + 0) / 10
        b_layout = w.Layout(width=f"{height}px", height=f"{height}px")
        self.number_buttons = [
            w.Button(description=str(n),layout=b_layout) for n in range(10)]

        # Events für die Maus-/Touchsteuerung registrieren
        self.canvas.on_mouse_down(self.start_draw)
        self.canvas.on_mouse_up(self.stop_draw)
        self.canvas.on_mouse_out(self.stop_draw)
        self.canvas.on_mouse_move(self.draw)
        self.canvas.on_touch_start(self.start_draw)
        self.canvas.on_touch_end(self.stop_draw)
        self.canvas.on_touch_cancel(self.stop_draw)
        self.canvas.on_touch_move(self.draw)
        self.submit_button.on_click(self.toggle_worker)
        self.clear_button.on_click(self.clear)
        w.interactive(self.choose_color, color=self.color_chooser)
        for n, button in enumerate(self.number_buttons):
            button.on_click(partial(self.draw_number, n))

        self.start_worker()

    def display(self):
        display(w.HBox([w.VBox(self.number_buttons),
                        self.canvas,
                        w.VBox([self.color_chooser,
                                w.HBox([self.clear_button, self.submit_button]),
                                self.result_text])]))

    def draw(self, x, y):
        if self.down:
            x = (round(x/self.scale) - self.line_width / 2) * self.scale
            y = (round(y/self.scale) - self.line_width / 2) * self.scale
            lw = self.scale * self.line_width
            self.canvas.fill_rect(x, y, lw, lw)
            self.changes_pending = True

    def draw_number(self, n: int, *_):
        assert 0<=n and n<10
        self.clear()
        self.put_image_data(
            self.numbers[n][np.random.randint(0, len(self.numbers[n]))])

    def start_draw(self, x, y):
        self.down = True
        self.draw(x, y)

    def stop_draw(self, x, y):
        self.down = False

    def choose_color(self, color):
        self.canvas.fill_style = 'white' if (color == 'weiß') else "gray"

    def clear(self, *args):
        tmp_color = self.canvas.fill_style
        self.canvas.fill_style = 'white'
        self.canvas.fill_rect(0, 0, self.canvas.width, self.canvas.height)
        self.canvas.fill_style = tmp_color
        self.changes_pending = True

    def get_image_data(self):
        try:
            s = slice(round(self.scale/2), None, self.scale)
            image_data = self.canvas.get_image_data()[s, s, 0]
            # Unser Modell erwartet Werte zwischen 0 und 1 wobei
            # schwarze Pixel ursprünglich mit 1 gekennzeichnet waren.
            # Da wir grau zum Zeichnen verwendet haben (R=G=B=128) setzen
            # wir alle Pixel mit einem Wert kleiner gleich 128 auf 1
            image_data = (image_data <= 128).astype(float)
        except:
            image_data = self.canvas.create_image_data(
                self.canvas.width//self.scale, self.canvas.height//self.scale)[..., -1]
            image_data = image_data / 255
        return image_data

    def put_image_data(self, image_data):
        # wiederhole die Pixel damit sie auf den Canvas passen
        data = image_data.repeat_interleave(
                self.scale, -2).repeat_interleave(self.scale, -1)[0]
        # konvertiere die Intensitaeten der Pixel in den RGB Bereich
        image = (1 - np.repeat(np.array(data)[:, :, np.newaxis], 3, axis=-1)) * 255
        self.canvas.put_image_data(image)
        self.changes_pending = True

    def inference(self):
        try:
            if self.model.mock:
                time.sleep(0.5)
            data = torch.tensor(
                self.get_image_data(), dtype=torch.float).unsqueeze(0)
            results_p = self.model(data.unsqueeze(0)).detach().flatten()
            results = reversed(results_p.argsort())
            results_t = [f"<h4>{i} <small>({results_p[i]:.0f})</small></h4>" for i in results]
            text = "<h3>Ergebnis:</h3>"
            text += ''.join(results_t[:5])
            self.result_text.value = text
        except:
            self.print_traceback()

    def print_traceback(self):
        """
        Zeigt eventuelle Fehler als roten Text auf der Oberfläche
        https://github.com/martinRenou/ipycanvas/issues/61
        """
        self.canvas.fill_style = '#ff8888'
        self.canvas.fill_rect(10, 10, 300, 300)
        self.canvas.fill_style = '#000000'
        s = traceback.format_exc()
        for i, si in enumerate(s.split('\n')):
            self.canvas.fill_text(si, 20, 30+15*i)

    def start_worker(self, *_):
        """ Startet einen neuen Hintergrundprozess """
        self.started_at = get_ipython().execution_count
        self.running = True
        self.submit_button.description = "Stop"
        self.submit_button.button_style = "danger"

        def work(zm: ZahlenMalen):
            while zm.running:
                if zm.changes_pending or True:
                    zm.changes_pending = False
                    zm.inference()
                else:
                    time.sleep(0.2)
                if self.autostop and get_ipython().execution_count > zm.started_at + 1:
                    zm.stop_worker()

        thread = threading.Thread(target=work, args=(self,))
        thread.start()

    def stop_worker(self, *_):
        # stop and update button
        self.running = False
        self.submit_button.description = "Start"
        self.submit_button.button_style = "success"

    def toggle_worker(self, *_):
        if self.running:
            self.stop_worker()
        else:
            self.start_worker()

# Anzeigen der Oberfläche zum Malen
model = Model()
zm1 = ZahlenMalen(model, numbers, line_width=2)
zm1.display()
../_images/girlsday_mnist_output2.png

In dem weißen Bereich kann man selber malen oder mit den Knöpfen auf der linken Seite eine Zahl malen lassen. Unter Ergebnis sollte dann ganz oben die wahrscheinlich richtige Ziffer erscheinen, darunter kommen die Ziffern, die das Modell als etwas weniger wahrscheinlich vorschlägt. Da herrscht jetzt vermutlich noch ein ziemliches Durcheinander, aber es wurde ja auch noch nicht trainiert!

Training des Modells

In diesem Teil soll nun ein Modell so trainiert werden, dass es möglichst gut handgeschreibene Ziffern erkennen kann. Dazu werden im Folgenden zwei Funktionen benötigt:

  • train zeigt dem Netzwerk nacheinander jedes Trainingsbeispiel und passt dabei die Gewichte, die Verbindungen zwischen den Neuronen, an.

  • test testet, wie gut das Netzwerk verallgemeinern kann. Dafür wird versucht die Testbeispiele (die nicht zum Trainieren verwendet wurden) zuzuordnen und das Ergebnis mit den richtigen Ziffern verglichen.

zm1.stop_worker()

def train(model: torch.nn.Module,
          loader: torch.utils.data.DataLoader,
          optimizer: torch.optim.Optimizer):
    """
    Diese Funktion trainiert das Modell.

    :param model: Das Modell
    :param loader: Die zu verwendenden Beispielbilder
    :param optimizer: Der Optimierer, der zum Anpassen des Modells
        verwendet werden soll
    """
    model.train()
    pbar = tqdm(
        total=len(loader), unit="batch", postfix=" " * 11, leave=False)
    for data, target in loader:
        optimizer.zero_grad()
        output = model(data)
        loss = torch.nn.functional.cross_entropy(output, target) / 64
        loss.backward()
        optimizer.step()  # automatisches Anpassen der Gewichte
        pbar.set_postfix(loss=f"{loss.item():.4f}")
        pbar.update()
    pbar.close()


def test(model: torch.nn.Module, loader: torch.utils.data.DataLoader) -> float:
    """
    Diese Funktion testet das Modell.

    :param model: Das zu testende Modell
    :param loader: Die zu verwendenden Beispielbilder
    :return: Die erreichte Genauigkeit
    """
    model.eval()
    loss = 0
    n_correct = 0
    n_total = len(loader.dataset)
    pbar = tqdm(total=len(loader), unit="batch", leave=False)
    with torch.no_grad():
        for data, target in loader:
            output = model(data)
            pred = output.argmax(dim=1, keepdim=True)
            n_correct += pred.eq(target.view_as(pred)).sum().item()
            pbar.update()
    pbar.close()
    accuracy = n_correct / n_total
    return accuracy
# Dieses Modell soll trainiert werden
model = Model()
print(model)

# Dieser Optimierer wird für das Training benötigt
# und übernimmt die Anpassung der Gewichte.
optimizer = torch.optim.Adam(
    model.parameters(),  # es sollen alle Gewichte trainiert werden
    lr=0.25,  # Geschwindigkeit, mit der gelernt werden soll
)

Trainiert wird nun in sogenannten Epochen, das heißt es werden die gleichen Beispiele immer wieder gezeigt. Dabei sollte das Netzwerk immer besser werden.

num_epochs = 4  # Die Anzahl der Trainingsepochen
for epoch in range(num_epochs):
    train(model, train_loader, optimizer)
    accuracy = test(model, test_loader)
    print(f"Genauigkeit: {accuracy*100:.2f}%")
../_images/girlsday_mnist_output3.png

Jetzt sollte das Netzwerk die gemalten Ziffern auch erkennen können:

zm2 = ZahlenMalen(model, numbers, line_width=2)
zm2.display()
../_images/girlsday_mnist_output4.png
  • Ihr werdet feststellen, das manche Ziffern einfacher erkannt werden als andere. Woran könnte das liegen?

  • Reicht es auch, nur eine halbe Ziffer zu malen?

zm2.stop_worker()