Updates dieser Seite:

  • 20.03.2022: v1 neues Semester

Überblick

In diesem Kapitel geht es um Unüberwachtes Lernen (unsupervised learning). Das heißt, die Trainingsdaten haben keine Labels/Klassen. Wir betrachten einerseits Hopfield-Netze und andererseits Autoencoder. Wir besprechen nur kurz die weiterführenden Konzepte von Hopfield-Netzen - Boltzmann Machine und Restricted Boltzmann Machine - sowie den Variational Autoencoder.

Konzepte

Hopfield-Netze, (Boltzmann Machine), (Restricted Boltzmann Machine), Autoencoder, (Variational Autoencoders)

1 Hopfield-Netze: Basics

Ein Hopfield-Netz nennt man auch assoziatives Gedächtnis (Hopfield 1982). Es handelt sich um eine Reihe von Neuronen, die sowohl für Eingabe als auch für Ausgabe zuständig sind. Im Gegensatz zu einem Feedforward-Netz schickt man also nicht einen Vektor $x$ mit Länge $n$ in das Netz, um dann einen Vektor $y$ mit Länge $m$ herauszubekommen. Stattdessen gibt man einen Vektor $x$ mit Länge $n$ in das Netz, lässt es ein Weile "rechnen" und bekommt einen Vektor $\hat{x}$ mit der gleichen Länge $n$. Man kann sich ein Foto $x$ vorstellen, das verrauscht oder unvollständig ist. Die Ausgabe $\hat{x}$ ist das ent-rauschte oder vervollständigte Foto.

In der Abbildung unter (B) sieht man das Szenario, wo das Bild "entrauscht" wird.

Quelle: Vortrag von Melanie Weber, Princeton Univ.

Damit so ein Szenario funktioniert, muss man das Hopfield-Netz trainieren. Das nennen wir Trainingsphase. In der Trainningphase werden Daten gespeichert, indem die Gewichte angepasst werden.

Als Beispiel nehmen wir eine Reihe von Bildern $(x_1, \ldots, x_N)$, die man das Netz "lernen" lässt. Diese Bilder "kennt" das Netz nach dem Training. In der Abb. unter (A) sieht man, dass der Buchstabe H im Traning im Netz "abgelegt" wird.

Anschließend können wir in der Rechenphase ein beliebiges Bild $x$ eingeben, welches vom Netz verarbeitet wird (hier sind wir wieder bei B in der Abbildung). Das Netz verändert seinen Zustand, bis es sich auf eine Ausgabe $\hat{x}$ stabilisiert. Diese Ausgabe $\hat{x}$ ist dann eines der ursprünglich trainierten Beispiele $x_1, \ldots, x_N$. Deshalb der Begriff assoziatives Gedächtnis. Das Netz versucht bei einer Eingabe $x$ zu assoziieren, welches bekannte Bild am besten dazu passt. Die Ausgabe kann natürlich auch eine Mischung oder eine Verzerrung eines bekannten Bildes sein. Die Gewichte bleiben in dieser Phase natürlich fix.

Siehe auch: Vorlesungsvideos von Geoffrey Hinton von 2013 Lecture 11: Hopfield nets and Boltzmann machines

1.1 Definition

Ein Hopfield-Netz ist eine Menge von $n$ Neuronen. Jedes Neuron hat einen Zustand $s_i\in\{-1, +1\}$, d.h. die Zustände sind entweder -1 oder 1, man sagt auch sie sind diskret. Wir nennen die Menge der Zustände $S = (s_1, \ldots, s_n)$.

Zwischen den Neuronen gibt es ungerichtete Verbindungen mit Gewichten $w_{i,j}$. Ungerichtet heißt, dass $w_{i,j} = w_{j,i}$. Alle Gewichte sind durch die symmetrische nxn-Matrix $W = (w_{i,j})$ gegeben. Neuronen sind nicht mit sich selbst verbunden, also $w_{i,i}=0$.

Hier ein Beispiel für eine symmetrische 3x3-Matrix mit $w_{i,i}=0$.

$$ \begin{pmatrix} 0 & -1.2 & 0.8 \\ -1.2 & 0 & 1.1 \\ 0.8 & 1.1 & 0 \end{pmatrix} $$

Alternativer Wertebereich

Der Wertebereich von $\{-1,1\}$ führt zu etwas eleganteren Formeln, aber man kann einen Wert $x\in\{-1,1\}$ immer in einen $\tilde{x}\in\{0, 1\}$ umrechnen und umgekehrt:

$$ \begin{align} x &= 2 \,\tilde{x} - 1\\[3mm] \tilde{x} &= \frac{1}{2}\,(x + 1) \end{align} $$

1.2 Rechenphase

Wir stellen die Frage, woher der Gewichte genau kommen (Trainingsphase) erstmal zurück und fragen uns, wie die Rechenphase bei Eingabe $x$ aussieht. Die Gewichte sind in dieser Phase bereits gelernt und bleiben immer fix.

Legt man in der Rechenphase einen Vektor $x = (x_1, \ldots, x_n)$ an die Neuronen des Hopfield-Netzes an, d.h. $s_i := x_i$, dann werden in Runden alle Neuronen durchlaufen und nach einer Update-Regel werden die Zustände der Neuronen entweder verändert oder bleiben gleich.

Bei der asynchronen Verarbeitung werden die Neuronen in zufälliger Reihenfolge durchlaufen und die Veränderung des Zustands des jeweiligen Neurons tritt sofort in Kraft.

Bei der synchronen Verarbeitung werden alle Neuronen durchlaufen und der jeweils neue Zustand wird zwischengespeichert, aber nicht für die weitere Berechnung verwendet. Erst wenn alle neuen Zustände berechnet sind, treten die Änderungen in Kraft.

Die Update-Regel für ein Neuron $i$ lautet:

$$ s_i := \begin{cases} 1& \quad \text{falls} \quad \sum_{j=1}^n w_{i,j} s_j + b_i > 0\\ -1&\quad\text{sonst} \end{cases} $$

Dabei ist $b_i$ der Bias. Dieser wird in der Literatur auch als Schwellwert bezeichnet, dann i.d.R. in der Form $\sum w_{i,j} s_j > \theta_i$. Wir setzen im weiteren für unsere Beispiele alle Biaswerte auf Null.

Überlegen wir uns ein Beispiel mit zwei Neuronen $s_1$ und $s_2$. Wir wählen zunächst eine positives Gewicht $w_{1,2}=1$, dann gilt:

$$ s_1 := \begin{cases} 1& \quad \text{falls} \quad s_2 > 0\\ -1&\quad\text{sonst} \end{cases} $$

Da $s_2$ entweder $1$ oder $-1$ ist:

$$ s_1 := \begin{cases} 1& \quad \text{falls} \quad s_2 = 1\\ -1&\quad\text{sonst} \end{cases} $$

Jetzt wählen wir ein negatives Gewicht $w_{1,2}=-1$, dann gilt:

$$ s_1 := \begin{cases} 1& \quad \text{falls} \quad s_2 = -1\\ -1&\quad\text{sonst} \end{cases} $$

Man beachte, dass die genaue Größe von $w_{1,2}$ bei diesen zwei Neuronen keine Rolle spielt, nur das Vorzeichen des Gewichts ist ausschlaggebend. Das ändert sich natürlich, wenn es mehrere Neuronen gibt, die in die Summe mit eingehen.

Das bedeutet, ein positives Gewicht zwischen zwei Neuronen bedeutet, dass sich die Werte angleichen. Ein negatives Gewicht bedeutet, dass sich die Werte "abstoßen".

Rolle der Gewichte

Wir schauen uns ein paar einfache Beispiele mit zwei Neuronen $s_1$ und $s_2$ an.

Im ersten Beispiel (siehe Abb. unten) berechnen wir für $s_1$ einen Wert von $1 \cdot 1 = 1$, d.h. $s_1 = 1$. Analog für $s_2$. Das Netz ist also stabil und verändert sich nie. Das liegt an dem Gewicht $1$ und der Tatsache, dass beide Neuronen bereits gleiche Werte haben.

Das nächste Beispiel ist ähnlich, nur das die Werte $-1$ betragen. Wir berechnen für $s_1$ einen Wert von $1 \cdot (-1) = -1$, d.h. $s_1 = -1$. Analog für $s_2$. Das Netz ist ebenfalls stabil und ändert sich nie.

Im nächsten Beispiel wählen wir als Gewicht $-1$, welches gegensätzliche Werte an Nachbarneuronen erzwingt. Wir berechnen für $s_1$ einen Wert von $-1 \cdot (-1) = 1$, d.h. $s_1 = 1$. Für $s_2$ rechnen wir $1 \cdot (-1) = -1$, d.h. $s_2 = -1$. Auch dieses Netz ist stabil, weil die Neuronen bereits gegensätzliche Werte haben.

Zum Schluss ein Beispiel, in dem das Netz oszilliert, d.h. es wechselt ständig zwischen zwei Belegungen hin und her. Rechnen Sie gern nach.

In größeren Netzen kommt es auch zu Oszillation, aber nicht ganz so schnell wie oben bzw. in größeren Zyklen oder nur in Teilbereichen des Netzes.

Versuchen Sie, anhand von Beispielen mit mehreren Neuronen Berechnungen durchzuführen, z.B. hier:

1.3 Trainingsphase

Wie passt man die Gewichte an, wenn man bestimmte Trainingsmuster gegeben hat?

Energie

Ähnlich wie beim Gradientenabstieg definieren wir eine Zielfunktion, die minimiert werden soll. Wir nennen diese Funktion Energie des Netzwerks.

Die Zielfunktion nennen wir Energie $E$. Sie hängt von den Zuständen $S$ und Gewichten in $W$ ab:

$$ E = - \left( \sum_{i=1}^n \sum_{j=1}^{i-1} w_{i,j}\,s_i s_j + \sum_{i=1}^n b_i\,s_i \right) $$

Beachten Sie, dass die Summe mit einem Minuszeichen versehen ist und dass wir $E$ ja minimieren möchten. Das heißt positive Summanden sind "gut", weil sie die Energie senken, und negative Summanden sind "schlecht", weil die Energie steigt.

Die Summanden $ w_{i,j}\,s_i s_j $ bestehen aus drei Faktoren. In folgenden Fällen sind die Summanden positiv, also "gut" im Sinne der Zielfunktion:

  • Gewicht $w_{i,j}$ ist positiv und beide Zustände $s_i, s_j$ haben das gleiche Vorzeichen
  • Gewicht $w_{i,j}$ ist negativ und beide Zustände $s_i, s_j$ haben unterschiedliche Vorzeichen

Dies ist ja auch, was wir erreichen wollen: dass die Gewichte mit ihrem Vorzeichen diese zwei Fälle abbilden.

Für alle folgende Fälle ist der Summand negativ, also "schlecht" im Sinne der Zielfunktion:

  • Gewicht $w_{i,j}$ ist positiv und beide Zustände $s_i, s_j$ haben unterschiedliche Vorzeichen
  • Gewicht $w_{i,j}$ ist negativ und beide Zustände $s_i, s_j$ haben das gleiche Vorzeichen

Wenn wir $E$ minimieren, wird unser Netz mehr und mehr die Idee modellieren, dass ein positives Gewicht Ähnlichkeit abbildet und ein negatives Gewicht Gegensätzlichkeit.

Hebbsche Regel

Ziel des Trainings ist es, die Gewichte schrittweise so anzupassen, dass Energie $E$ minimiert wird. Das führt dann zu Gewichten, welche die Trainingsbeispiele assoziativ "speichern". Es kann gezeigt werden, dass mit Hilfe der Hebbschen Regel die Energiefunktion minimiert werden kann.

Nehmen wir an, wir haben $N$ Trainingsbeispiele $X^k = (x^k_1, \ldots, x^k_n)$, wobei $x_i \in \{-1,+1\}$. Im Training suchen wir über alle $k$ das Energieminimum, indem wir die Gewichte $w_{i,j}$ anpassen. Im Gegensatz zu Feedforward-Netzen werden die Biaswerte nicht verändert.

Die Hebbsche Regel lässt uns alle Gewichte in einem einzigen Schritt berechnen:

$$ \tag{HR} w_{i,j} := \frac{1}{N} \sum_{k=1}^N x^k_i x^k_j $$

Wir können auch eine inkrementelle Update-Regel definieren. Hier wenden wir für jedes Trainingsbeispiel $k$ die folgende Regel an:

$$ w_{i,j} := w_{i,j} + \alpha \, x^k_i x^k_j $$

Hier kommt - ähnlich wie bei Backpropagation - eine Lernrate $\alpha$ zur Anwendung, die das Tempo der Anpassung steuert.

Wie kann man die Hebbsche Regel (HR) "lesen"? Wenn zwei Neuronen $i$ und $j$ für ein Trainingsbeispiel gleich aktiviert sein sollen - also beide +1 oder beide -1 - dann wird das Gewicht verstärkt (positiver Summand). Sind die Neuronen im Trainingsbeispiel gegensätzlich aktiviert - also eines +1 und eines -1 - dann wird das Gewicht reduziert (negativer Summand). Das erinnert sehr an das Prinzip in der Rechenphase.

Eine alternative Lernmethode ist die Regel von Storkey (Storkey 1997), welche eine höhere Kapazität des Netzes erlaubt (Kapazität wird im nächsten Abschnitt erklärt).

Hintergrund: Donald O. Hebb (1904-1985) war Psychologoe und Neurowissenschaftler an der McGill-Universität, Kanada. Die Hebbsche Lernregel wird auch manchmal verkürzt als fire together, wire together wiedergegeben. Die Grundidee ist, dass Verbindungen zwischen ähnlich feuernden Neuronen im Gehirn verstärkt werden. Hebb hat dies insbesondere 1949 in seinem Buch "Organization of Behavior - A Neuropsychological Theory" formuliert. Später wurde dann auch (biologische) Evidenz für dieses Prinzip gefunden, aber besondere Bedeutung hat die Lernregel eben im Bereich des maschinellen Lernens.

1.4 Kapazität

Man kann zeigen bzw. es ist auch theoretisch plausibel, dass Hopfield-Netze nur eine begrenzte Kapazität haben, d.h. es können nur begrenzt viele Bilder (um bei diesem Beispiel zu bleiben) gespeichert werden.

Wenn man zu viele Trainingsbeispiele speichern möchte, relativ zur Anzahl der Neuronen, treten zusehends Fehler beim Abruf auf.

Die Kapazität eine Hopfield-Netzes liegt bei ca. 14%, d.h. bei 100 Neuronen können etwa 14 Trainingsbeispiele zuverlässig gespeichert werden.

Eine genauere Berechnung erlaubt die folgende Formel, wo $n$ die Anzahl der Neuronen bezeichnet:

$$ C = \frac{n}{2 log_2 n} $$

Bei 100 Neuronen kommt man hier auf eine Kapazität von $C = 7.5$.

1.5 Hopfield-Netz in Python

Es ist sicherlich instruktiv, ein Hopfield-Netz selbst zu programmieren. Hier sind zwei kleine Python-Projekte auf GitHub mit entsprechendem Code als Anhaltspunkte:

2 Hopfield-Netze: Weiterentwicklung

2.1 Hopfield-Netz mit versteckten Neuronen

Man kann die Ausdrucksstärke von Hopfield-Netzen erhöhen, indem man versteckte Neuronen einzuführt. Bislang ist jedes Neuron Teil des Inputs und des Outputs. Im Fall von Bildern repräsentiert jedes Neuron einen Pixel.

Jetzt kann man zwischen sichtbaren (visible) Neuronen und versteckten (hidden) Neuronen unterscheiden. Nur die sichtbaren Neuronen repräsentieren den Input/Output (z.B. ein Bild), wohingegen die versteckten Neuronen nur die Verarbeitung unterstützen.

Die versteckten Neuronen werden genauso behandelt wie die sichtbaren Neuronen, erlauben aber, komplexere Zusammenhänge zwischen den sichtbaren Neuronen abzubilden.

2.2 Boltzmann-Maschine und Restricted Boltzmann Machine

Die Boltzmann-Maschine (BM) kann man auch als stochastisches Hopfield-Netz mit versteckten Neuronen bezeichnen (Ackley, Hinton & Sejnowski 1985). Bei der BM sind die Zustände Wahrscheinlichkeiten und keine fixen Werte. Dies macht die BM u.a. zu einer interessanten Option, um neue Bilder zu generieren.

Siehe auch den Artikel Boltzmann Machine auf Tutorialspoint.

Die Restricted Boltzmann Machine (RBM) ist wiederum eine Spezialform der BM, bei denen die Verbindungen eingeschränkt sind (Smolensky 1986): Es gibt keine Verbindungen zwischen den Neuronen einer Schicht (sichtbar vs. versteckt) untereinander (intra-layer), sondern nur zwischen den Schichten. Mathematische gesehen ist das Netz ein bipartiter Graph.

Quelle: https://de.wikipedia.org/wiki/Boltzmann-Maschine

Ein effizienter Trainingsalgorithmus, Contrastive Divergence (basierend auf Gibbs Sampling), wurde von Hinton (2002) entwickelt.

Siehe auch:

3 Autoencoder

Autoencoder sind NN, die neue, meist kompaktere Repräsentationen für einen Datensatz lernen, ähnlich wie wir es schon bei den Word Embeddings kennen gelernt haben. Es handelt sich um unüberwachtes Lernen, d.h. es werden keine Labels benötigt.

Interessante praktische Anwendungen von Autoencodern sind:

  • Entfernung von Rauschen
  • Dimensionsreduktion für die Datenvisualisierung

3.1 Autoencoder: Basics

Autoencoder sind im einfachsten Fall ganz normale Feedforward-Netze. Das Besondere ist, dass die Ausgabeschicht die gleiche Größe wie die Eingabeschicht hat, und dass das Ziel des Netzes ist, die jeweilige Eingabe exakt gleich an der Ausgabe zu reproduzieren.

Wir schauen uns als Beispiel ein 3-Schichten-Netz an:

Die Länge des Eingabevektors sei $n$. Dieser soll gleich lang sein wie die Ausgabe, also auch $n$. Die Größe der versteckten Schicht $m$ ist deutlich kleiner als $n$. Die Grundidee ist, dass wir eine Menge an Datenvektoren $(x^1, \ldots, x^N)$ haben und bei jedem Trainingsbeispiel dem Netz zum Ziel geben, den exakt gleichen Input auch wieder auszugeben. Wir trainieren also im Grunde das Netz mit den Daten $(x^1, x^1), (x^2, x^2), \ldots, (x^N, x^N)$. Wenn die Zwischenschicht genauso groß wäre wie die Eingabe, würde das Netz wahrscheinlich einfach die Identität lernen, aber da $m < n$, lernt das Netz eine "komprimierte" Repräsentation der Trainingsdaten.

Siehe auch Wikipedia.

3.2 Variational Autoencoder (VAE)

Ein Variational Autoencoder (VAE) ist ein "generatives" Modell, d.h. mit diesem Mechanismus kann man z.B. neue Bilder, Texte oder Musikstücke erzeugen. Was hat Generierung mit Autoencodern zu tun? Ganz einfach: ein Autoencoder besteht aus einem Encoder und einem Decoder. Dazwischen ist die komprimierte Repräsentation. Man könnte sich überlegen, die Repräsentation mit Zufallswerten zu belegen und nur den Decoder zu benutzen, dann hätte man ein neuen Bild (zum Beispiel) generiert.

Das Problem ist, dass diese Repräsentation zwischen Encoder und Decoder beim normalen Autoencoder nicht gut geeignet zum Generieren ist. Der Variational Autoencoder wendet statistische Verfahren an, um diese Repräsentation für den Zweck der Generierung zu optimieren.

Soviel muss erstmal reichen. Hier noch einige Artikel dazu.

Keven Frans, 2016: Variational Autoencoders Explained

Joseph Rocca, 2019: Understanding Variational Autoencoders (VAEs)

4 Autoencoder in Keras

Jetzt bauen wir eigene Autoencoder in Keras. Dies ist eine angepasste Version eines Blog-Artikels von Francois Chollet.

Wir arbeiten mit den MNIST-Daten. Ein Autoencoder ist nichts anderes als ein Feedforward-Netz, im einfachsten Fall mit einer einzigen Zwischenschicht:

(Quelle: Obiger Artikel von Chollet)

In dem Bild ist der Encoder die Zwischenschicht, der Dekoder ist die Ausgabeschicht.

4.1 Daten: MNIST

Wir greifen auf die bekannten MNIST-Daten zurück. Zur Erinnerung: es handelt sich um 60000 Trainings- und 10000 Testbeispiele von handgeschriebenen Ziffern (0..9) als 28x28-Bitmaps (in Graustufen).

Wir laden zunächst die Daten. Dabei ignorieren wir die Label, denn unsere gewünschte Ausgabe ist wieder das Originalbild und nicht eine Klassifizierung in 0...9.

In [1]:
from tensorflow.keras.datasets import mnist
import numpy as np

(train_x, _), (test_x, _) = mnist.load_data()

Wir normalisieren und linearisieren.

In [2]:
train_x = train_x / 255.0
test_x = test_x / 255.0

train_x = train_x.reshape((len(train_x), 784))
test_x = test_x.reshape((len(test_x), 784))

print(train_x.shape, test_x.shape)
(60000, 784) (10000, 784)

4.2 Einfacher Autoencoder

Wir nutzen die Functional API von Keras, um den Autoencoder zu definieren. Das erlaubt uns, ein Teil des Netzes als eigenes Netz zu definieren. Einerseits definieren wir den Encoder, wo wir nur die Eingabeschicht und die versteckte Schicht hineindefinieren. Andererseits den Decoder, wo wir die Ausgabeschicht verwenden.

Zu beachten ist, dass Encoder/Decoder wirklich dieselben Schichten wir der vollständige Autoencoder verwenden, d.h. die trainierten Gewichte sind ebenfalls dieselben.

Zunächst definieren wir die Größe der versteckten Schicht als Konstante:

In [3]:
NUM_HIDDEN = 36

Jetzt definieren wir die drei Schichten:

In [4]:
from tensorflow.keras.layers import Input, Dense
from tensorflow.keras.models import Model

input = Input(shape=(784,))
hidden = Dense(NUM_HIDDEN, activation='relu')(input)
output = Dense(784, activation='sigmoid')(hidden)

Encoder

Der Encoder bildet den Input, ein 28x28-Bild bzw. einen Array mit 784 Werten, auf eine komprimierte Repräsentation mit - in unserem Fall - 36 Werten ab. Dazu fügen wir nur die Eingabeschicht und die versteckte Schicht hinzu.

In [5]:
encoder = Model(input, hidden)

Autoencoder

Der eigentliche Autoencoder bildet dann die komprimierte Repräsentation mit 36 Werten wieder of die originale Dimension von 784 Werten ab.

In [6]:
autoencoder = Model(input, output)

Decoder

Jetzt bauen wir uns noch einen Decoder. Wir müssen eine neue Eingabeschicht definieren, die 36 Werte erwartet. Dazu nehmen wir die Ausgabeschicht, die wir bereits definiert hatten. Das Modell kann dann einen komprimierten Vektor auf ein 28x28-Bild abbilden.

In [7]:
# Neue Eingabeschicht in der Größe der Zwischenschicht
input_hidden = Input(shape=(NUM_HIDDEN,))

# Existierende Ausgabeschicht des Autoencoders
last_layer = autoencoder.layers[-1]

decoder = Model(input_hidden, last_layer(input_hidden))

Training

Wir trainieren unseren Autoencoder. Damit trainieren wir automatisch den Encoder und Decoder.

In [8]:
from tensorflow.keras.optimizers import Adam

autoencoder.compile(optimizer=Adam(learning_rate=0.0001), 
                    loss='binary_crossentropy')
In [9]:
history = autoencoder.fit(train_x, train_x,
                          verbose = 0,
                          epochs=30,
                          batch_size=256,
                          shuffle=True,
                          validation_data=(test_x, test_x))

Ergebnis

In [10]:
import matplotlib.pyplot as plt

def show_loss(h):
    plt.plot(h['loss'], label='train loss')
    plt.plot(h['val_loss'], label='val loss')
    plt.legend()
    plt.show()
Bad key "text.kerning_factor" on line 4 in
/Users/kipp/anaconda3/envs/nndl/lib/python3.7/site-packages/matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle.
You probably need to get an updated matplotlibrc file from
https://github.com/matplotlib/matplotlib/blob/v3.1.3/matplotlibrc.template
or from the matplotlib source distribution
In [11]:
show_loss(history.history)
In [12]:
score = autoencoder.evaluate(test_x, test_x)
score
10000/10000 [==============================] - 0s 35us/sample - loss: 0.1136
Out[12]:
0.11360005309581757

Wir erzielen einen Loss-Wert von 0.11. Unklar ist, ob das ein hinreichend guter Wert ist.

Visuelle Inspektion

Um einen Eindruck von der Qualität unseres Autoencoder zu bekommen, probieren wir ihn auf Bildern auf den Testdaten aus.

Dabei möchten wir auch sehen, was in der Zwischenschicht passiert. Das können wir mit dem Encoder, der Vektoren der Länge 36 produziert, die wir als 6x6-Bilder darstellen können.

Der Autoencoder produziert aus den Eingabebildern wieder 28x28-Bilder, die (hoffentlich) so aussehen wie die Orignalbilder.

In [13]:
encoded = encoder.predict(test_x)
pred = autoencoder.predict(test_x)

Wir schauen uns die ersten 10 Bilder an. In der ersten Zeile das Original, in der zweiten Zeile die Zwischenschicht, in der letzten Zeile die Ausgabe.

In [14]:
import matplotlib.pyplot as plt

n = 10  # how many digits we will display
plt.figure(figsize=(20, 4))
for i in range(n):
    # display original
    ax = plt.subplot(3, n, i + 1)
    plt.imshow(test_x[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(3, n, i + 1 + n)
    plt.imshow(encoded[i].reshape(6, 6))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)

    # display reconstruction
    ax = plt.subplot(3, n, i + 1 + n + n)
    plt.imshow(pred[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Man sieht, dass die ursprünglichen Bilder trotz der drastischen Reduktion von 784 auf 36 Werte doch recht gut funktioniert. Der Fehlerwert von 0.11 scheint also "gut" zu sein.

Die Darstellung der versteckten Schicht ist in diesem Fall nicht sonderlich informativ.

4.3 Rauschen entfernen

Können wir mit Hilfe unserer Repräsentation verrauschte Bilder rekonstruieren?

Wir stellen zunächst verrauschte Varianten der Bilder in Trainings- und Testdatensatz her. Wir multiplizieren jeden Pixel einfach mit einer Zufallszahl. Diese Zufallszahl ist normalverteilt um die 1 herum. Mit noise_factor können wir den Grad des Rauschens einstellen.

In [15]:
from tensorflow.keras.datasets import mnist
import numpy as np

noise_factor = 0.5
train_x_noisy = train_x + noise_factor * np.random.normal(loc=0.0, 
                                                          scale=1.0, 
                                                          size=train_x.shape) 
test_x_noisy = test_x + noise_factor * np.random.normal(loc=0.0, 
                                                        scale=1.0, 
                                                        size=test_x.shape) 

train_x_noisy = np.clip(train_x_noisy, 0., 1.)
test_x_noisy = np.clip(test_x_noisy, 0., 1.)
In [16]:
train_x_noisy.shape
Out[16]:
(60000, 784)

Wir inspizieren exemplarisch ein paar verrauschte Bilder.

In [17]:
n = 10
plt.figure(figsize=(20, 2))
for i in range(n):
    ax = plt.subplot(1, n, i+1)
    plt.imshow(test_x_noisy[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Jetzt wenden wir unseren Autoencoder von oben auf die verrauschten Daten an.

In [18]:
train_pred = autoencoder.predict(train_x_noisy)
In [19]:
n = 10
plt.figure(figsize=(20, 2))
for i in range(n):
    ax = plt.subplot(2, n, i+1)
    plt.imshow(train_x[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(2, n, n+i+1)
    plt.imshow(train_pred[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Das Ergebnis überzeugt nicht. Der Autoencoder benötigt "saubere" Bilder.

Neuer Autoencoder

Daher trainieren wir einen neuen Autoencoder. Neu ist, dass wir als Eingabe jeweils das verrauschte Exemplar verwenden, als Zielausgabe nehmen wir das Original.

In [20]:
from tensorflow.keras import Sequential

auto2 = Sequential()
auto2.add(Dense(NUM_HIDDEN, input_dim=784, activation='relu'))
auto2.add(Dense(784, activation='sigmoid'))
auto2.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dense_2 (Dense)              (None, 36)                28260     
_________________________________________________________________
dense_3 (Dense)              (None, 784)               29008     
=================================================================
Total params: 57,268
Trainable params: 57,268
Non-trainable params: 0
_________________________________________________________________

Training

Hier verwenden wir train_x_noisy für das Training.

In [21]:
auto2.compile(optimizer=Adam(learning_rate=0.0001), 
                    loss='binary_crossentropy')

history = auto2.fit(train_x_noisy, train_x,
                    verbose=0,
                    epochs=30,
                    batch_size=256,
                    shuffle=True,
                    validation_data=(test_x_noisy, test_x))
In [22]:
show_loss(history.history)
In [23]:
score = auto2.evaluate(test_x, test_x)
score
10000/10000 [==============================] - 0s 37us/sample - loss: 0.3097
Out[23]:
0.30973674235343934

Wir erreichen nur einen Fehlerwert von 0.31, das ist deutlich schlechter als die 0.11 von oben.

Visuelle Inspektion

Sehen wir uns die Bilder an. Zunächst wenden wir den neuen Autoencoder auf Trainings- und Testdaten an.

In [24]:
auto2_train_pred = autoencoder.predict(train_x_noisy)
auto2_test_pred = autoencoder.predict(test_x_noisy)

Jetzt geben wir uns 10 Bilder aus aus den Trainingsdaten aus.

In [25]:
n = 10
plt.figure(figsize=(10, 2))
for i in range(n):
    ax = plt.subplot(2, n, i+1)
    plt.imshow(train_x[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(2, n, n+i+1)
    plt.imshow(auto2_train_pred[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Das Ergebnis überzeugt nicht.

4.4 CNN-Variante

Konvolutionsnetze sind für Bilddaten oft deutlich besser geeignet als Netze mit Fully-Connected-Schichten. Daher probieren wir es jetzt mit einem Konv-Netz.

Dazu müssen wir zunächst die Daten auf 2D-Format (inklusive Channels) bringen.

In [26]:
train2d = train_x.reshape(60000, 28, 28, 1)
train2d_n = train_x_noisy.reshape(60000, 28, 28, 1)
test2d = test_x.reshape(10000, 28, 28, 1)
test2d_n = test_x_noisy.reshape(10000, 28, 28, 1)
train2d_n.shape
Out[26]:
(60000, 28, 28, 1)

Wir erstellen ein symmetrisches Konv-Netz mit zwei Konv-Schichten (je 32 Filter mit Filtergröße 3x3) und jeweiligen Poolingschichten für das Enkodieren. So erreichen wir eine Kompression auf 7x7 (mit 32 Kanälen).

Das Dekodieren läuft ebenfalls über zwei Konv-Schichten. Statt des Poolings haben wir hier ein Upsampling. Hier werden ganz einfach die Werte gedoppelt, um von 7x7 auf 14x14 auf 28x28 zu kommen.

Siehe auch: https://keras.io/api/layers/reshaping_layers/up_sampling2d

In [27]:
from tensorflow.keras.layers import Input, Dense, Conv2D, MaxPooling2D, UpSampling2D

input_img = Input(shape=(28, 28, 1))  

x = Conv2D(32, (3, 3), activation='relu', padding='same')(input_img)
x = MaxPooling2D((2, 2), padding='same')(x)
x = Conv2D(32, (3, 3), activation='relu', padding='same')(x)
encoded = MaxPooling2D((2, 2), padding='same')(x)

# Repräsentation ist hier: (7, 7, 32)

x = Conv2D(32, (3, 3), activation='relu', padding='same')(encoded)
x = UpSampling2D((2, 2))(x)
x = Conv2D(32, (3, 3), activation='relu', padding='same')(x)
x = UpSampling2D((2, 2))(x)
decoded = Conv2D(1, (3, 3), activation='sigmoid', padding='same')(x)

Hier werden Encoder und Autoencoder zusammengestellt.

In [28]:
convEncoder = Model(input_img, encoded)
autoConv = Model(input_img, decoded)
autoConv.summary()

autoConv.compile(optimizer=Adam(learning_rate = 0.0001), 
                 loss='binary_crossentropy')
Model: "model_4"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
input_3 (InputLayer)         [(None, 28, 28, 1)]       0         
_________________________________________________________________
conv2d (Conv2D)              (None, 28, 28, 32)        320       
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 14, 14, 32)        0         
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 14, 14, 32)        9248      
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 7, 7, 32)          0         
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 7, 7, 32)          9248      
_________________________________________________________________
up_sampling2d (UpSampling2D) (None, 14, 14, 32)        0         
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 14, 14, 32)        9248      
_________________________________________________________________
up_sampling2d_1 (UpSampling2 (None, 28, 28, 32)        0         
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 28, 28, 1)         289       
=================================================================
Total params: 28,353
Trainable params: 28,353
Non-trainable params: 0
_________________________________________________________________

Training

Beim Training verwenden wir die verrauschten Daten als Input und die originalen Daten als Output.

In [29]:
history = autoConv.fit(train2d_n, train2d,
                       verbose=0,
             epochs=6,
             batch_size=128,
             shuffle=True,
             validation_data=(test2d_n, test2d))
In [30]:
show_loss(history.history)
In [31]:
score = autoConv.evaluate(test2d_n, test2d)
score
10000/10000 [==============================] - 3s 262us/sample - loss: 0.1149
Out[31]:
0.1149255590558052

Wir sehen hier einen deutlich besseren Fehlerwert von 0.11. Praktisch genau so gut, wie das FNN auf den unverrauschten Daten performt hat.

In [32]:
train_pred = autoConv.predict(train2d_n)
train_enc = convEncoder.predict(train2d_n)
test_pred = autoConv.predict(test2d_n)
test_enc = convEncoder.predict(test2d_n)
In [33]:
train_enc.shape
Out[33]:
(60000, 7, 7, 32)

Visuelle Inspektion

Wir möchten uns 10 Beispielbilder an sehen: die verrauschten Bilder, zwei Zeilen mit Encodings (wir haben 32 aufgrund der 32 Filter) und die Ausgabe in der unteren Zeile.

Die Encodings liegen in 32 Kanälen vor, wobei der Kanal in der vierten Dimension kodiert ist. Wir vergegenwärtigen uns kurz, wie man mit Slicing einen Filter freistellt:

In [34]:
a = np.arange(8).reshape((2,2,2))
print(a[:,:,0])
print(a[:,:,1])
[[0 2]
 [4 6]]
[[1 3]
 [5 7]]
In [35]:
num = 4
n = 10
plt.figure(figsize=(30, 10))
for i in range(n):
    ax = plt.subplot(num, n, i+1)
    plt.imshow(train2d_n[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(num, n, n+i+1)
    plt.imshow(train_enc[i,:,:,0].reshape(7,7))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(num, n, 2*n+i+1)
    plt.imshow(train_enc[i,:,:,1].reshape(7,7))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(num, n, 3*n+i+1)
    plt.imshow(train_pred[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Das Ergebnis sieht in der Tat ähnlich gut aus wie die Rekonstruktion den FNN auf den unverrauschten Daten.

Hier sehen wir uns noch weitere Filter an.

In [36]:
num = 4
n = 10
plt.figure(figsize=(30, 10))
for i in range(n):
    ax = plt.subplot(num, n, i+1)
    plt.imshow(train2d_n[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(num, n, n+i+1)
    plt.imshow(train_enc[i,:,:,2].reshape(7,7))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(num, n, 2*n+i+1)
    plt.imshow(train_enc[i,:,:,3].reshape(7,7))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(num, n, 3*n+i+1)
    plt.imshow(train_enc[i,:,:,4].reshape(7,7))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Und hier noch Beispiel aus den Testdaten.

In [37]:
n = 10
plt.figure(figsize=(10, 2))
for i in range(n):
    ax = plt.subplot(2, n, i+1)
    plt.imshow(test2d_n[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
    
    ax = plt.subplot(2, n, n+i+1)
    plt.imshow(test_pred[i].reshape(28, 28))
    plt.gray()
    ax.get_xaxis().set_visible(False)
    ax.get_yaxis().set_visible(False)
plt.show()

Das CNN scheint hinreichend zu verallgemeinern.

5 Generative Adversarial Networks (GANs)

Bei dem Thema Neuronaler Netze kann man sich fragen, ob NNs auch neues Material erzeugen können, seien es Bilder oder Texte. Damit wären Maschinen in der Lage, menschliche Kreativität nachzubilden. Ein wichtiger Meilenstein in diese Richtung sind Generative Adversarial Networks oder kurz GANs.

GANs wurden von Goodfellow et al. (2014) erfunden. GANs erlauben das Erzeugen von neuen Bildern, die ähnlich zu einem Datensatz von Orignalbildern sind. So kann man z.B. Fake-Fotos herstellen, die so ähnlich aussehen wie ein Originaldatensatz. Das funktioniert mit Hilfe von zwei Netzen, die "gegeneinander" arbeiten.

Die Beispielbilder unten zeigen Originaldaten und - mit gelben Rahmen - entsprechend generierte Fake-Daten. Bei (a) handelt es sich um die MNIST-Daten, bei (c) und (d) um die CIFAR-10-Daten, bei (b) um den TFD-Datensatz (Toronto Faces Dataset).

Quelle: Goodfellow et al. (2014)

Das Generator-Netz (G) erzeugt neue Bilder (Fake-Bilder) auf Basis eines Vektors mit Zufallswerten. Netz G ist sozusagen "der Fälscher".

Das Discriminator-Netz (D) bekommt ein echtes Bild und ein Fake-Bild als Eingabe und muss entscheiden, welches der beiden echt ist. Netz D ist quasi "der Detektiv".

Quelle: Salvaris et al. 2018

Eine schöne Einführung gibt der Artikel A Gentle Introduction to Generative Adversarial Networks (GANs) von Jason Brownlee (2019).

6 Literatur

Ackley, David H.; Hinton, Geoffrey E.; Sejnowski, Terrence J. (1985) A Learning Algorithm for Boltzmann Machines. In: Cognitive Science 9: 1, pp. 147–169.

Goodfellow, Ian J.; Pouget-Abadie, Jean; Mirza, Mehdi; Xu, Bing; Warde-Farley, David; Ozair, Sherjil; Courville, Aaron; Bengio, Yoshua (2014) Generative adversarial nets. In: Proceedings of the 27th International Conference on Neural Information Processing Systems - Volume 2 (NIPS'14), pp. 2672–2680. arXiv

Hinton, Geoffrey E. (2002) Training Products of Experts by Minimizing Contrastive Divergence. In: Neural Computation 14: 8, pp. 1771–1800.

Hopfield, John J. (1982) Neural networks and physical systems with emergent collective computational abilities. In: Proceedings of the National Academy of Sciences of the USA, 79: 8, pp. 2554–2558.

Smolensky, Paul (1986) Information Processing in Dynamical Systems: Foundations of Harmony Theory. In: Parallel Distributed Processing: Explorations in the Microstructure of Cognition, Volume 1: Foundations, MIT Press, pp. 194–281.

Storkey, Amos (1997) Increasing the capacity of a Hopfield network without sacrificing functionality. In: Proc. of the International Conference on Artificial Neural Networks (ICANN), Springer, pp. 451-456.