Bernd Thomas
Beck et al.
Published in
11 min readMar 9, 2020

--

6.7 Neuronale Netzt zum Lernen von Treppenfunktionen

In 6.6 hatten wir eine angepasste Sigmoid-Funktion als Aktivierungsfunktion definiert, die den Anstieg steiler und zwischen 0 und 1 hatte, anders als die Standard 'sigmoid' Funktion. Erreicht wurde das durch Verändern von 2 Parametern: 1/(1+exp(-a*(x-b))) mit a=5, b=0.5. Durch Anpassen des Zählers kann man offensichtlich auch die Höhe "über Null" beeinflussen, was allerding in 6.6 kontraproduktiv gewesen wäre.

Wenn man den Parameter a größer macht, kann man den Funktionsverlauf im Anstiegsbereich beliebig steil machen, so dass die "Kurve" nahezu stufenförmig aussieht.

Die Idee dazu kam aus [1] M. Nielsen's Blog Neural Networks and Deep Learning. Dort zeigt er, dass man, im Prinzip, jede stufenförmige Funktion (mathematisch: Treppenfunktion) durch einfache neuronale Netze approximieren kann.

Die Erklärung dafür ist einfach: Ein einfaches Neuron mit dem Gewicht w und dem Bias b errechnet aus dem Input x den Ausgabewert z=w*x+v. In die sigmoid-Funktion als Aktivierung geht -z als Exponent der exp-Funktion im Nenner ein. Man kann nun -z einfach umformen in

-z = -(w*x+v) = -w*(x+v/w) = -a*(x-b) mit a=w, b= -v/w.

D.h. die Neuron-Parameter wund v "verschieben" die sigmoid-Funktion um v/w entlang der x-Achse (Input-Achse), und w bestimmt darüberhinaus die "Steilheit" des Anstiegs von 0 auf 1. Wird das Aktivierungsergebnis im Output-Neuron noch mit s gewichtet, steigt die Ergebniskurve des NN insgesamt von 0 auf s.

Mann kann statt einem Neuron auch einen Hidden Layer mit mehreren Neuronen in dieser Weise anlegen. Für jedes liefert die Gewichte-Kombination w, v eine solche, modifizierte Sigmoid-Funktion, die ihren "Sprung" an den entsprechenden Stellen der x-Achse hat. Die Kombination (gewichtete Summation) dieser Aktivierungen im Output-Neuron führt mit den Gewichten s zu einer stufenartigen Funktion - wobei Sprünge "nach unten" durch negative s-Werte erzeugt werden.

Da man jede stetige Funktion durch Treppenfunktionen beliebig genau approximieren kann, haben wir damit einen weiteren Ansatz für die "Universelle Approximationseigenschaft von Neuronalen Netzen.

Was dort aber fehlt und hier unser Hauptaugenmerk ist, ist die Frage, ob ein Neuronales Netz lernen kann, die Stufen zu approximieren.

Am Ende werden wir die angepasste Sigmoid-Funktion als Aktivierung bei den neuronalen Netzen für Boolesche Funktionen durch eine 1-Neuron-Schicht ersetzen.

6.7.1 Die erste Stufe

Wir zeigen das an einem ersten Beispiel. Das NN ist hier ein einfaches hidden Neuron, das eine Inputgröße x aufnimmt und den Output des Neurons mit der Standard-Sigmoid-Funktion “aktiviert” und an das Output-Neuron weitergibt.

import numpy as np
import numpy.random as rd
import matplotlib.pyplot as plt
from keras.models import Model
from keras.layers import Dense, Input
from keras.optimizers import sgd, adam
inp = Input(name='In',shape=(1,))x = Dense(1,name='D_1',activation='sigmoid')(inp)
out = Dense(1,name='Out',activation='linear',use_bias=False,)(x)
m1 = Model(inputs=[inp],outputs=[out])
m1.summary()
#m1.get_weights()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
In (InputLayer) (None, 1) 0
_________________________________________________________________
D_1 (Dense) (None, 1) 2
_________________________________________________________________
Out (Dense) (None, 1) 1
=================================================================
Total params: 3
Trainable params: 3
Non-trainable params: 0
_________________________________________________________________

Das Modell hat 3 trainierbare Parameter: das Kernel-Gewicht und den Bias-Wert des Neurons, und die Gewichtung des Inputs in das Ausgabe-Neuron. Da wir den Wert des Ausgabe-Neurons unverändert haben wollen, ist der Bias-Wert untrainierbar auf 0 gesetzt und die Aktivierungsfunktion 'linear'. Das keras-Modell wird kompiliert mit adam als Optimizer.

lrate= 0.1    # learning rate

opt = adam(lrate)
m1.compile(optimizer=opt,loss='mean_squared_error',
metrics=['accuracy'])

Als Trainingsdaten legen wir x-Werte zwischen -2.0 und 4.0 in Schritten von 0.1 fest. Die zu approximierende Funktion (blau) gleicht einer leichten Stufe von 0 auf 2.0 mit dem Sprung bei etwa 1.7.

# Generate x's to calulate modified sigmoid function:
# sig(x) = level*sigmoid(w*x+b)

X = np.arange(-2.0,4.0,0.1)
y_pred = m1.predict(X)

bt = -18 # fixed bias
wt = 10 # fixed kernel weight
s_level = 2.0

ex = wt * X + bt
y_true = s_level/(1+np.exp(-ex))

plt.plot(X,y_true)
plt.plot(X,y_pred,'r')
plt.show()

Die Werte, die das Modell — untrainiert — errechnet, haben nichts mit der vorgegebenen Stufenfunktion zu tun (rot).

Abb. 1: Werte NN-Modell untrainiert (rot) und Trainingsvorgabe (blau)

Trainiert wird das Modell wie üblich mit der fit-Methode, hier über 200 Epochen:

n_ep = 200
m1.fit(X,y_true,epochs=n_ep,batch_size=5,verbose=False)
y_pred = m1.predict(X)
plt.plot(X,y_pred,'r')
plt.plot(X,y_true)
plt.show()
Abb. 2: Ergebnis nach Training des Modells

Das Ergebnis: Die Kurvenverläufe sind nun fast deckungsgleich. Das Modell konnte augenscheinlich gut auf die Vorgabefunktion trainiert werden.

m1.get_weights()[array([[9.247084]], dtype=float32),
array([-16.475222], dtype=float32),
array([[2.0300844]], dtype=float32)]

Die Gewichte nach Training weichen zwar immer noch von den Parametern der Vorgabe ab, sie sind aber gut genug für eine visuell sehr gute Approximation. Die trainierten Gewichte entsprechen von oben nach unten den Parametern wt, bt und s_level.

Wir können die Trainingsgüte in hier sogar analytisch erkennen. Das trainierte Modell errechnet per predict die Funktionswerte mittels des Exponenten

z = -(9.25*x-16.5) = -9.25*(x-16.5/9.25) = -9.25*(x-1.78)
und dem Anstieg auf s=2.03.
Die Vorgabe (Trainingsdaten) war:
z = -10.0*(x-18/10) = -10*(x-1.8), s=2.0

6.7.2 Mehr Stufen — Treppenfunktionen

Wir zeigen nun, wie man eine mehrstufige Treppenfunktion als Neuronales Netz generieren kann — wieder analog der Referenz [1] und exemplarisch für eine dreistufige Funktion.

Das keras-Modell hat einfach nur 3 Neuronen (n_steps) statt einem Neuron in der Hidden Schicht.

n_steps = 3
inp = Input(shape=(1,))
x = Dense(n_steps,activation='sigmoid',kernel_initializer=k_param,bias_initializer=b_param,trainable=False)(inp)
out = Dense(1,activation='linear',kernel_initializer=out_param,use_bias=False,trainable=False)(x)
m3 = Model(inputs=[inp],outputs=[out])
m3.summary()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) (None, 1) 0
_________________________________________________________________
dense_1 (Dense) (None, 3) 6
_________________________________________________________________
dense_2 (Dense) (None, 1) 3
=================================================================
Total params: 9
Trainable params: 0
Non-trainable params: 9

Wir verwenden zudem die Möglichkeit, die Gewichte (Kernel und Bias) vor-einzustellen. Die Initializer k_param, b_param, out_param sind vorher zu definieren:

# Parameters and Initializers

# 1st layer neurons
b1 = -100
w1 = 100
b2 = -200
w2 = 100
b3 = -300
w3 = 100
# Output neuron
s_level_1 = 1.0
s_level_2 = 1.0
s_level_3 = 1.0


def k_param(shape, dtype=None):
s = np.array([[w1,w2,w3]]) # Kernel weights to adjust steepness of sigmoid "jumps"
return s
def b_param(shape, dtype=None):
s = np.array([b1,b2,b3]) # Bias weights to adjust position of sigmoid "jumps"
return s
def out_param(shape, dtype=None):
s = np.array([[s_level_1],[s_level_2],[s_level_3]]) # Kernel weights to adjust height of "jumps"
return s

Die Parameter sind so gesetzt, dass alle "jumps" die gleiche hohe Steilheit und "Stufenhöhe" haben (w=100, s=1.0), die Position der Stufen aber gegeneinenander versetzt sind - per -b/w = 1.0, 2.0 bzw. 3.0.

Wir berechnen das Modell für die "Testdaten" von 6.7.1 mit den Ausgangsgewichten (rot) und vergleichen das Ergebnis in der Grafik mit der echten, analytisch definierten Treppenfunktion step_func()(blau).

X = np.arange(-1.0,4.0,0.01) 


def step_func(x,steps=0): # True analytic step funtione
if x <= 0:
return 0.0
elif x > steps:
return steps
else:
return int(x)

v_step_func = np.vectorize(step_func)
y_true = v_step_func(X,steps=3)

y_pred = m3.predict(X) # Calculation by model

plt.plot(X,y_true)
plt.plot(X,y_pred,'r')
plt.show()
Abb. 3: Dreistufige Treppenfunktion berechnet durch NN-Modell

Verblüffend? Die blaue Kurve ist unter der roten Kurve des Modells kaum noch zu erkennen. Andererseits auch wieder nicht überraschend, wie oben erklärt. Aber immerhin, hier wird eine nicht-stetige Funktion approximiert!

Trainierbarkeit

Mit passend vorgegebenen Gewichten kann das neuronale Modell die Funktion offensichtlich sehr gut approximieren. Die Frage ist wieder, kann es das auch “lernen”. D.h. können die Gewichte durch Input-Daten so trainiert werden, dass anschließend das NN die Funktion ebensogut approximiert?

Unmittelbar klar ist das nicht — die Fehler-Backpropagation Verfahren beim Lernen Neuronaler Netze kommen über nicht-stetige Funktionen nicht gut hinweg (Gradienen-Berechnung). Wir versuchen es trotzdem. Zunächst ohne Startgewichtvorgabe, d.h. mit der keras-standardmässigen Zufallsauswahl. Die Modell-Definition mit n_stap=3 ist die gleiche wie oben, lediglich die initializer-Optionen fallen weg.

Abb. 4a: Vorgabefunktion (blau) und Berechnung des untrainierten NN (rot)

Die Grafik zeigt die zu trainierende Funktion (blau) und die per Modell und den Startgewichten berechnete Kurve (rot) über dem Intervall -1.0 bis 4.0. Wie zu erwarten, nicht die geringste Ähnlichkeit.

Wir trainieren nun das Modell mit der Vorgabefunktion, in Schritten von 100, 200, 300 Epochen bis zu 1000 und mehr Epochen. Ab etwa 500 Trainingsepochen ändert sich das Ergebnis (per m.predict(X) nicht mehr (rote Kurve):

Abb. 4b: Vorgabe (blau) und trainiertes NN (rot) nach 1000 Epochen

Netter Versuch — aber offensichtlich erfolglos als Approximation! Ausgehend von den simplen Standardeinstellungen des Optimizers und beliebigen Startgewichten ist das NN nicht trainierbar.

Es gibt eine Reihe von Möglichkeiten, den Optimizer anzupassen, z.B. zu verhindern, dass der Gradient “zu groß” wird, oder, dass die Korrekturen zu stark ausfallen. Hier hilft Ausprobieren.

Weiteren Einfluß hat, wie wir schon wissen, die inititalen Gewichte. Wir führen daher zunächst weitere Trainierbarkeitstests mit Startgewichten “in der Nähe” der theoretischen Werte (s.o.) durch; z.B. mit Zufallswerten, die mit einer gewissen Standardabweichung um die theoretischen Werte normalverteilt sind:

# Trainable model

# Weghts Initializing: random normal around theoretical values

# Standard deviations
eta = 15
beta = 15
delta = 15

# 1st Layer
b1 = rd.normal(-100,beta)
w1 = rd.normal(100,delta)
b2 = rd.normal(-200,beta)
w2 = rd.normal(100,delta)
b3 = rd.normal(-300,beta)
w3 = rd.normal(100,delta)
# Output neuron
s_level_1 = rd.normal(1.0,eta)
s_level_2 = rd.normal(1.0,eta)
s_level_3 = rd.normal(1.0,eta)

# For tests using order of magnitude
# variation in weights inititalization
# for 1st layer
scale=1.0

# 1st Layer
b1 *= scale
w1 *= scale
b2 *= scale
w2 *= scale
b2 *= scale
w2 *= scale


# Initializer k_param, b_param, out_param as above

#....

Damit definieren wir das Modell m3 wieder wie oben und schauen uns zunächst wieder die Ergebnisse des untrainierten Modell an:

Abb. 5a: Vorgabe (blau) und untrainiertes NN (rot) mit normalverteilten Startgewichten

Das Modellergebnis (rot) zeigt zwar schon Stufen, aber an völlig falschen Stellen und mit falschen Sprunghöhen. Das erklärt sich schon alleine aus den Größenordnungen für die Gewichte. Tests mit kleiner skalierten Werten zeigen weiniger ausgeprägte oder gar keine Stufen.

Nach dem Training (100 bzw. 300 Epochen) sieht das Ergebnis so aus:

Abb. 5b: Ergebnis des trainierten NN nach 100 Epochen (rot)
Abb. 5c: Das Trainingsergebnis (rot) nach 300 Epochen

Die Grafik zeigt ein sehr gutes Approximationsergebnis (rot, auf blau) bereits nach 300 Epochen! Auch hier zeigt sich, dass die Größenordnung der Startwerte für die Gewichte eine große Rolle spielen, nicht dagegen die Genauigkeit (hier: Standardabweichung 15.0 zugelassen). Die Niedrig-Skalierung der Startgewichte und Varianz in Layer 1 mit 0.1 oder 0.01 verschlechtern das Ergebnis deutlich, bis hin zur Nicht-Trainierbarkeit.

Erweiterbarkeit

Die Approximationsaufgaben lassen sich prinzipiell erweitern auf vielstufige Treppenfunktionen, letztlich bis hin zu dem Ziel, eine kontinuierliche Funktion (wie z.B. in 6.1) durch Treppenfunktionen zu approximieren. Die Berechnung durch NN-Modelle erfordert lediglich die passende Anzahl von Neuronen und die Einstellung der theoretischen Gewichte — wie hier beim 3-stufigen Beispiel dargestellt.

Die Trainierbarkeit ist wieder eine andere Frage. Wir verzichten hier auf weitere Tests, da das Prinzip klar ist. Es ist zu erwarten, dass mit größerer Komplexität der zu trainierenden Treppenfunktion das Training schwieriger wird oder gar nicht mehr erfolgreich ist, dass bzw. die Startwerte der Gewichte sehr nahe bei den theoretischen Werten liegen müssen.

In [1] wird darüberhinaus gezeigt, wie auch Stufen-Funktionen mit zwei Input-Variablen durch entsprechende NN-Modelle berechnet werden können.

Die Erweiterbarkeit wird an einigen Beispielen in Bonustrack A: Variationen in Stufen aufgegriffen.

6.7.3 Die Endstufe im XOR-Modell — Eine Anwendung

Beim Lernen von Logik-Funktionen in 6.6 wurde zur “Schärfung” der Ergebnisse aus dem NN eine customizierte Sigmoid-Funktion als Aktivierung eingesetzt. Für mehr-komponentige Logik-Strukturen ist das hilfreich, da das Forwarding der Ergebnisse aus einem Logik-Gate zu den nachfolgenden weniger “Unschärfe” transportiert. In 6.6. war das ein Aktivierungstrick. Mit den Erkenntnissen in diesem Abschnitt zur Approximierbarkeit von Treppenfunktionen können wir versuchen, diesen Trick bei den Booleschen Funktionen ebenfalls zu “lernen”.

Die custom_sigmoid Aktivierung ergibt folgende Kurve:

# custom_sigmoid aus 6.6
def custom_sigmoid(x):
a = 5
b = 0.5
return 1/(1+K.exp(-a*(x-b)))
# Plot custom_sigmoid for x in -2.0 ti 4.0
Abb. 6: Die bei den Boolschen Funnktionen in 6.6 verwendete Aktivierungsfunktion

Wir verwenden zunächst das einstufige Modell m1aus 6.7.1 und trainieren die custom_sigmoid-Funktion mit Trainingsdaten von -2 bis 4 in Schritten von 0.1 über 200 Epochen. Das Bild zeigt die Trainingsdaten als blaue Punkte und die trainierte Kurve durchgezogen in rot.

Abb. 7: Trainiertes Modell für die custom_sigmoid Aktivierung
m1.get_weights()[array([[4.540467]], dtype=float32),
array([-2.26822], dtype=float32),
array([[1.0029165]], dtype=float32)]

Wir interpretieren die trainierten Gewichte analog 6.7.1. Damit gibt wt=4.54 die Steilheit und -bt/wt=4.54/2.27=0.5 die Verschiebung der Standard-Sigmoiden auf der x-Achse an. Wir bekommen hier also eine sehr gute Übereinstimmung auch mit den Paramtern der vorgegebenen Funktion.

Diese Erkenntnis lässt sich auf das Lernen der Logikfunktionen anwenden. Wir testen das an der XOR-Funktion. Dazu wird das ursprüngliche XOR NN-Modell so modifiziert, dass die custom_sigmoid-Aktivierung zusätzlich in das Training von XOR mit einbezogen werden kann. Das Modell m_xor bekommt dazu eine weitere 1-Neuron-Schicht.

Die Schichten ‘hidden’ und ‘out’ sind wie beim zweischichtigen Perzeptron für XOR in 6.6.4. Die letzte Schicht, ‘sig’ ersetzt die Vorgabe der custum_sigmoid Funktion als Aktivierung. Die Aktivierungen sind nun Standard: relu und sigmoid für die ersten beiden Layer bzw. für 'sig'.

# XOR extended
# Using the simple 1 hidden layer and 2 nodes model
# with standard relu or custom sigmoid activation

X = [[0,0],[1,0],[0,1],[1,1]]
y_xor = [0,1,1,0]

act1, act2 = 'relu', 'relu'
act0 = 'sigmoid'

# Model definition
inp = Input(shape=(2,))
x = Dense(2,name='hidden',activation=act1,use_bias=True)(inp)
x = Dense(1,name='out',activation=act2,use_bias=True)(x)
out = Dense(1,name='sig',activation=act0,use_bias=True)(x)

m_xor = Model(inp,out)
sgd1 = sgd(lr= 0.01) # sgd with learning rate
m_xor.compile(optimizer=sgd1,loss='mean_squared_error',metrics=['accuracy'])

m_xor.summary()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_22 (InputLayer) (None, 2) 0
_________________________________________________________________
hidden (Dense) (None, 2) 6
_________________________________________________________________
out (Dense) (None, 1) 3
_________________________________________________________________
sig (Dense) (None, 1) 2
=================================================================
Total params: 11
Trainable params: 11
Non-trainable params: 0

Wir trainieren das Modell mit den XOR-Daten und einer Standard-Zufallsauswahl für die Startgewichte. Die Konvergenz ist recht langsam, daher trainieren wir über 1000 Epochen. Danach liefert die Auswertung mit m_xor.predict die korrekten Input-Output-Wahrheitswerte des XOR-Gates.

Interessant ist es, einen Blick auf die ‘sig’-Schicht zu werfen, die ja die custom_sigmoid-Aktivierung ersetzen sollte. Im Trainingsbeispiel erhalten wir für diese Schicht z.B. die Endgewichte

Kernel: 2.0194986 , Bias: -1.4300333

Umgerechnet in Steilheit und Verschiebung entlang der x-Achse haben wir damit

wt = 2.019 und bt = 1.43/2.019 = 0.708

Das ist weniger “steil” als bei der custom_sigmoid-Funktion (5.0) und etwas weiter "verschoben" (0.5), reicht aber aus, um die XOR-Funktion sauber zu implementieren. Denn anders als bei dem vorausgehenden Modell wird hier nicht allein die custom_sigmoid-Funktion trainiert, sondern das XOR-Gate insgesamt.

Zum Vergleich zeigt die Grafik die drei Kurven den Zusammenhang visuell: rot die custom_sigmoid, blau die Kurve berechnet aus der trainierten 'out'-Schicht und, zusätzlich, in schwarz die Standard-Sigmoide.

Abb. 8: Sigmoide: Standard (schwarz), custom (rot), XOR-trainiert (blau)

Weiter lesen: Bonustrack - Weitere nicht so einfache Aufgaben für KI

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