Bernd Thomas
Beck et al.
Published in
9 min readOct 14, 2019

--

3.1 Gerade / Ungerade Lernen mit Neuronalen Netzten — Naiver Versuch, Variationen und Brute Force

pixabay.com

Wir versuchen ein — hinreichend komplexes — Neuronales Feed-Forward Netz (NN) zu definieren und zu trainieren, das anhand der Zeichenkette einer ganzen Zahl entscheiden kann, ob die Zahl gerade oder ungerade ist.

Was uns an dieser Stelle zuversichtlich stimmt, ist die theoretische Aussage, dass jede Funktion durch ein NN dargestellt werden kann, wenn es nur genügend Neuronen und Schichten hat. Was das in unserem Fall bedeutet, untersuchen wir in Teil 6.

Die Architektur des NN ist ebenfalls ein (Hyper-)Paramter und kann ggf. angepasst werden.

Um irrelevante Schwierigkeiten außen vor zu lassen, machen wir folgende praktische Vorgaben, die aber keine Einschränkung der Allgemeingültigkeit bedeuten:

  • Zahlenbereich: ganze Zahlen mit bis zu 4 Stellen
  • Die Zahlen sind positiv, d.h. wir vermeiden die Behandlung des Minus-Zeichens
  • Als Input werden die Zahlen in Zeichenketten der Länge 4 gewandelt, ggf. mit vorlaufenden Nullen auf 4 Zeichen ergänzt: 234 -> '0234'

Wir achten wieder darauf, dass keine a priori Vorgaben gemacht werden, woran eine gerade Zahl erkennbar wird, etwa in Form einer Regel oder durch Fokus auf die letzte Stelle (“Einer”).

Wir probieren zunächst, ein mehrschichtiges NN darauf zu trainieren, gerade/ungerade zu unterscheiden.

Architektur-Ansatz: Mehrschichtiges Feed Forward NN

Das NN wird wie folgt strukturell festgelegt:

  • Die 3-schichtige Architektur besteht aus dem Input Layer, einem Hidden Layer und dem Output Layer
  • Input Layer: 4 Inputs d0,…d3 für die Ziffern in der Zeichenkette, die der Zahl 1000\*d0 + 100\*d1 + 10\*d2 + d3 entspricht
  • Hidden Layer: 4 Knoten (Neuronen), fully connected mit dem Input Layer, Default Aktivierungsfunktion (Linear)
  • Output Layer: 2 Knoten, fully connected mit Hidden Layer und Aktivierungsfunktion (z.B. softmax)
  • Damit ergeben sich als trainierbare Modell-Paramter: Input -> Hidden: 4x4 + 4x1 (sog. Bias), Hidden -> Output: 4x2 + 2x1 (Bias). Insgesamt also 20 + 10 = 30 Paramter ("Freiheitsgrade")

Das NN wird hier mit keras/tensorflow implementiert.

Diese Architektur is mehr oder weniger willkürlich so gewählt. Mal sehen, ob uns das irgendwo hin bringt …

NN Modell-Architektur Übersicht:

Modell-Architektur model_442

Die konkreten Model Settings (Hyper-Parameter) werden im compile und fit gesetzt.

Generieren der Daten: Generieren von 200 Zufallszahlen zwischen nmin = 0 und nmax = 9999 und der zugehörigen korrekten Klassifikation (0 für gerade, 1für ungerade).

'''Wir erzeugen Datasets und die Kategorisierung
als numpy arrays: Ein 1-dimensionales Z mit
Zufallszahlen zwischen 0 und 9999.
Die Kategorisierung y ist 0 für gerade, 1 für ungerade'''

import numpy as np
import random as rd

rd.seed(42)

nmin,nmax = 0,10000
n_cases = 200
N = n_cases

Z = np.random.randint(nmin,nmax,size=(n_cases,1))
y = Z[:] % 2 # Hier erzeugen wir die Klasse (gerade/ungerade) für die Summe

print(' Ein Beispiel: Zahl: ',Z[10,0],' Kategorie: ',y[10,0])
Ein Beispiel: Zahl: 3446 Kategorie: 0

Zahlen zu Zeichenketten: Umwandlung des Zahlen-Sets in ein Set von 4-stelligen Ziffern-Ketten, ggf. mit vorlaufenden Nullen, als Input für das NN.

''' Wir erzeugen die 4-Zeichen Strings zu den Zahlen in Z
inklusive Padding vorlaufender Nullen '''
def pad_zeros(k,s): # Padding mit Nullen
t = str(s)
while len(t) < k:
t = '0' + t
return t # String der Länge k
v_pad = np.vectorize(pad_zeros) # numpy allows vectorizing of functions, instead of 'map()'
D = v_pad(4,Z) # Padding der Ziffernketten auf Länge 4
[D[i,0] for i in range(10)] # Beispiel-Output['1339',
'6968',
'2091',
'8993',
'5908',
'4718',
'4374',
'9439',
'8121',
'7092']

Genau genommen sollen die Zeichenketten zeichenweise von der Input-Schicht kommen. Jedes Input-Neuron ist für eine bestimmte “Stelle” in der Zeichenkette zuständig. Im feed-forward zum Hidden Layer werden die Inputs gewichtet, daher wandeln wir die Zeichenketten weiter in Listen einzelner Ziffern um.

'''Erzeugen eines Input Arrays aus den Zeichenketten in D
Shape ist z.B. (200,4) d.h. 200 Inputs je 4 digits '''
digits = [list(map(int,list(d))) for d in D[:,0]]
X = np.array(digits) # Erzeugt ein 2-dim Array
print(X.shape)
[X[i] for i in range(10)]
(200, 4)
[array([1, 3, 3, 9]),
array([6, 9, 6, 8]),
array([2, 0, 9, 1]),
array([8, 9, 9, 3]),
array([5, 9, 0, 8]),
array([4, 7, 1, 8]),
array([4, 3, 7, 4]),
array([9, 4, 3, 9]),
array([8, 1, 2, 1]),
array([7, 0, 9, 2])]

One-Hot Codierung: Die Klassenwerte 0 und 1 werden in Wahrscheinlichkeitsvektoren abgebildet: y: 0 -> [1,0], 1 -> [0,1] (0 für gerade, 1 für ungerade).

Da wir nur zwei Kategorien haben, kann die one-hot Codierung allerdings auch entfallen, da die “Abstände” zwischen den beiden Kategorien in beiden Fällen gleich sind (Es gibt nur einen “Abstand”).

Wir verwenden dazu das ML Package keras für tensorflow, das hierfür die Funktion to_categorical bereit stellt.

# One-hot mit keras

from keras.utils.np_utils import to_categorical

n_cat = 2
y_hot = to_categorical(y,n_cat)

# Output ....
Die ersten 10 Klassifikationen mit der one-hot Kodierung:
[(1, [0.0, 1.0]),
(0, [1.0, 0.0]),
(1, [0.0, 1.0]),
(1, [0.0, 1.0]),
(0, [1.0, 0.0]),
(0, [1.0, 0.0]),
(0, [1.0, 0.0]),
(1, [0.0, 1.0]),
(1, [0.0, 1.0]),
(0, [1.0, 0.0])]

Training Set und Test Set: Aufteilung des Daten Sets als 80:20 % mittels train_test_split aus sklearn. Später definieren wir auch noch ein Validation Set, d.h. eine Teilmenge der Trainingsdaten, die vom Training ausgespart bleiben und nur zur Bewertung des Training-Fortschritts dienen.

''' Aufteilen der Daten X in ein Trainingset (80%) 
und ein Testset (20%).
X enthält die Ziffern der Zahlen als Vektor.
Zufallsauswahl mit gleichverteilten Kategorien'''
# Verwendung von sklearn train-test-split from sklearn.model_selection import train_test_split# random_state gesetzt zur Reproduziertbarkeit, stratify für gleichverteilte Kategorien
# y_train, y_test werden aus den one-hot codierten Klassen (y_hot) gebildet
X_train,X_test,y_train,y_test = train_test_split(X,y_hot,test_size=0.2,random_state=10,stratify=y)# Output ...

Generieren des Feed Forward NN Modells mit keras

Mit Hilfe von keras werden die Layers spezifiziert.

'''Verwenden keras.layers um Input layer, hidden layer und output layer zu erzeugen '''

# Architektur: 4 inputs, 4 hidden, 2 outputs

from keras.layers import Input, Dense

inputs = Input(shape=(4,)) # Input layer mit 4 inputs
# Hidden layer mit 4 neurons, fully connected mit inputs
hidden_fc = Dense(4)(inputs)
# Output layer mit 2 neurons, fc mit hidden_fc
# mit "softmax" als Aktivierungsfunktion
outputs_fc = Dense(2,activation='softmax')(hidden_fc)

Nun wird das Modell generiert. Die Bezeichnung model_442 soll die Layer-Architektur 4-4-2 andeuten.

'''Verwenden keras.models um das NN Model zu erzeugen'''

# NN Modell: aus inputs und hidden_fc

from keras.models import Model

model_442 = Model(input=inputs,output=outputs_fc) # inputs und outputs_fc aus der Spezifikation

print('Summary des erzeugten model_442:')
model_442.summary()
Summary des erzeugten model_442:
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) (None, 4) 0
_________________________________________________________________
dense_1 (Dense) (None, 4) 20
_________________________________________________________________
dense_2 (Dense) (None, 2) 10
=================================================================
Total params: 30
Trainable params: 30
Non-trainable params: 0

Die Anzahl der Parameter (Gewichte) ergibt sich hier, wie oben beschrieben, aus der Anzahl der eingehenden “Connections” je Schicht plus einem sog. Bias-Parameter je Neuron.

Die Modell-Spezifikation wird nun an Tensorflow übergeben (compile). Dabei werden noch einige Verfahrensparameter gesetzt: 'adam' als Optimizer, 'categorical_crossentropy' als Fehlerfunktion und die 'accuracy'als Qualitätsmetrik. Um zu verstehen, was diese Verfahren und Funktionen bedeuten und welche Alternativen es gibt, muss man schon einen tieferen Blick in die Theorie der ML-Verfahren und keras/tensorflow werfen. In den folgenden Teilen, werden einige dieser Parameter abgewandelt bzw. erklärt.

'''Compile model_442 für Tensorflow
Verwendet "Adam" als Optimizer,
"cross-entropy" als Loss-Funktion,
"accuracy" als Metrics'''
model_442.compile(optimizer='adam',loss='categorical_crossentropy',metrics=['accuracy'])

Training des NN Modells model_442

Dabei wird von den Training Sets noch ein Anteil zur Validierung während des Trainings ausgezeichnet: Parameter 'validation_split'. Anders als das Test Set nimmt das Validierungsset am Training-Vorgang teil - allerdings nur zur Bewertung des Trainingsfortschritts und nicht als Trainingsdaten. Mit dem Paramter 'epochs' wird die Anzahl kompletter Trainingsdurchläufe fest gelegt, also die Anzahl der Iterationen. Mit dem Paramter 'batch_size' kann festgelegt werden, wieviele Daten aus dem Traningsset für jeden Durchlauf (zufällig) ausgewählt werden.

Die Ausgabe des Trainingslaufs gibt die Werte der verwendeten Loss-Funktion (loss) und der Accuracy (acc) für jede Epoche aus, jeweils für das Gesamt-Set und den Validation-Teil (val_loss, val_acc).

'''Training des Modells auf X-train,y_train, 
Anzahl Epochen und Anteil Validierungsset '''
n_epochs = 500 # Anzahl Epochen

model_442.fit(X_train,y_train,epochs = n_epochs,validation_split=0.3,batch_size=50)
Train on 112 samples, validate on 48 samples
Epoch 1/500
112/112 [==============================] - 1s 8ms/step - loss: 2.7004 - acc: 0.4732 - val_loss: 3.0886 - val_acc: 0.4167
Epoch 2/500
112/112 [==============================] - 0s 139us/step - loss: 2.6590 - acc: 0.4643 - val_loss: 3.0069 - val_acc: 0.4167
Epoch 3/500
112/112 [==============================] - 0s 279us/step - loss: 2.6178 - acc: 0.4554 - val_loss: 2.9338 - val_acc: 0.3750
.
.
.
Epoch 500/500
112/112 [==============================] - 0s 139us/step - loss: 0.6803 - acc: 0.5357 - val_loss: 0.7049 - val_acc: 0.5208

Trainingsergebnis: Das Ergebnis des Trainings nach 500 Epochen sieht nicht besonders gut aus: Der Fehler geht nicht unter 0.6 und die Accuracy (aus dem Validation Subset berechnet) erreicht nur Werte um 0.5 .

Vorhersage-Tests: Wir testen die Vorhersagefähigkeit des trainierten Modells mit

  • einem Test Input
  • dem kompletten Training Set
  • dem Test Set
# Test des trainierten Modells: Prediction für ein input-array # num() wird nur für die Zahldarstellung im Outout verwendet
def num(digits):
'''Erzeugt Dezimalzahl aus einer Ziffernliste'''
d_fact, z = 1, 0
for x in reversed(digits):
z += x*d_fact
d_fact *= 10
return z
for test_input in X_test[:10]:
y_pred = model_442.predict(np.array([test_input]))
print('Prediction für ',num(test_input))
print('p0:',y_pred[0,0],'\np1:',y_pred[0,1])
print('Vermutlich:','gerade' if y_pred[0,0] > y_pred[0,1] else 'ungerade')
Prediction für 9004
p0: 0.5343145
p1: 0.4656855
Vermutlich: gerade
Prediction für 2654
p0: 0.44304767
p1: 0.5569523
Vermutlich: ungerade
Prediction für 3201
p0: 0.5240562
p1: 0.47594386
Vermutlich: gerade
Prediction für 3863
p0: 0.4905528
p1: 0.50944716
Vermutlich: ungerade
Prediction für 1431
p0: 0.48875108
p1: 0.5112489
Vermutlich: ungerade
Prediction für 9141
p0: 0.57755506
p1: 0.42244503
Vermutlich: gerade
...

Schlecht geraten! Das sieht gar nicht gut aus.

Auswertung mit den Trainingsdaten, die dem Modell ja schon “bekannt” sind:

result = model_442.evaluate(X_train,y_train)
print('Evaluate model: Training set')
print('Loss: %5.3f Accuracy: %5.3f' % (result[0],result[1]))

Evaluate model: Training set
Loss: 0.688 Accuracy: 0.531

… und mit den “frischen” Testdaten:

result = model_442.evaluate(X_test,y_test)
print('Evaluate model: Test set')
print('Loss: %5.3f Accuracy: %5.3f' % (result[0],result[1]))

40/40 [==============================] - 0s 390us/step
Evaluate model: Test set
Loss: 0.697 Accuracy: 0.500

Das Ergebnis — enttäuschend

Das Lern-Ergebnis für dieses Modell (model_442) ist enttäuschend! Test-Input und Evaluation mit den Trainingsdaten selbst und dem Testdaten-Set liefern eine Accuracy von um die 0.5 — d.h. die Vorhersage-Richtigkeit ist etwa 50%, also wie gewürfelt!

Wenn auch stark zu vermuten ist (und in Internet Communities diskutiert wird), dass NN-Modelle mit dem Lernen von Gerade/Ungerade Schwierigkeiten haben, oder sogar ganz und gar ungeeignet sind, muss man an dieser Stelle zunächst einmal fest halten, dass es lediglich dem Autor nicht gelungen ist, ein mehrschichtiges Feed-Forward NN zu erstellen, das die Lern-Aufgabe löst. Vielleicht gibt es ja Modell-Varianten — bis hin zu convolutional oder recurrent NN’s — die besser geeignet sind. Um der Vielfalt nicht zu erliegen, benötigt man eine Strategie des Vorgehens. Ob also ML-Verfahren — und speziell NN’s — insgesamt “zu dumm” sind , die gerade/ungerade-Unterscheidung zu lernen (die “Arbeitshypothese” in Teil 1), hängt damit offenbar auch von der Intelligenz des Entwicklers— oder der Untersuchung — ab.

Modell-Varianten

Sofern wir mit den Trainingsdaten nicht irgendwelche Fehler eingeschleppt haben, bleibt uns nur die Möglichkeit, die Modell-Architektur zu variieren — also z.B. Anzahl Schichten und Anzahl Neuronen pro Schicht.

Das kann systematisch geschehen, z.B. indem kann man bei einfachsten NN’s beginnt und nach und nach mehr Komplexität einführt — oder indem man das model_442 geringfügig variiert und die Effekte beobachtet. Also eine Art back propagation auf Modell-Ebene.

Weitere Versuche mit NN’s unterschiedlicher Architektur

Verschiedene mehrschichtige und einschichtige NNs führen zu ebenso unbefriedigenden Ergebnissen. So etwa Variationen von model_4_4_2: model_4_0_1, model_4_0_2, model_4_1_1, wobei die mittlere 0 andeutet, dass der Input direkt in die Output-Schicht geht, d.h. kein Hidden Layer verwendet wird.

Andere Experimente variieren

  • batch_size (1, 25, 50, alle)
  • die Aktivierungsfunktion (relu oder tanh) in der ersten Schicht
  • die Anzahl der Neuronen im Hidden Layer: 4, 40, 400, 1000
  • oder fügen einen weiteren Hidden Layer ein: model_4_1000_100_2

Gewisse Effekte sind mit höherer Komplexität durchaus zu erzielen, die sich aber nur auf die Anpassung an die verwendeten Traningsdaten auswirken. Mit relu, grösserer Anzahl an Neuronen im Hidden Layer erreicht man eine Training Accuracy von über 0.9. Das Modell model_4_1000_100_2 erreicht sogar eine Accuracy von 1.0, d.h. das Modell ist zu 100% angepasst auf die Trainigsdaten.

Zum einen darf das nicht verwundern: Wir haben 105203 trainierbare Parameter in diesem 4-schichtigen Modell, um 112 Zahlen korrekt zu klassifizieren! Hier greift tatsächlich die Universal Approximation Property von NN’s aufgrund der massiven Parameterzahl.

Andererseits liefert die Evaluation (Prediction) mit neuen Daten (Validation Set, Test Set) weiterhin nur magere Erfolge um rund 0.5 Accuracy. Also ein extremer Fall von Overfitting: Die Trainingsdaten werden korrekt klassifiziert, Testdaten nur zu wenig über 50%.

Im Kontext menschlichen Lernens interpretiert, heißt das nichts anderes als: Das NN lernt die vorgelegten Zahlen-Klassifikationen “auswendig”, kann aber bei neuen Zahlen nur “raten” und liegt damit bei ungefähr der Hälfte richtig. Das würde ein Münzwurf auch leisten. Technisch interpretiert ist das trainierte Netzwerk nichts anderes als ein (assoziativer) Speicher, wie etwa eine Datenbank oder ein Dictionary. Damit werden aber auch Fehler in den Trainingsdaten (siehe Teil 2, Robustheit) nicht kompensiert, sondern eins-zu-eins gespeichert.

In den nächsten Abschnitten gehen wir das Gerade/Ungerade Lernen mit NNs anders an.

Weiter lesen: 3.2 Ein erfolgreiches NN für ein Teilproblem der Gerade/Ungerade Klassifikatinsaufgabe

Zurück auf Anfang

bernhard.thomas@becketal.com
www.becketal.com

--

--

Bernd Thomas
Beck et al.

Dr. Bernhard Thomas — Mathematics, Theor. Biology, Computational Sciences, AI and advanced Technologies for the Enterprise. Beck et al. Consultant