Bernd Thomas
Beck et al.
Published in
10 min readNov 13, 2019

--

5.5 Lückenschluss — Ein NN zum Konvertieren von Zahlen in Ziffernfolgen

und anschließend (5.6) ein vollständiger Layer-Stack für das NN-Modell zur Klassifikation von Vielfachen von 3 (s. 5.2)

In 5.2 wurden zur “tiefen” Berechnung der Quersummen (Kontext: Vielfache von 3) bzw. der Reduktionen (Kontext: Vielfache von 7) Zahlen in Form von Zeichenketten (Ziffern) als Input benötigt, die Ergebnisse im Output Layer aber als Zahl ausgegeben. Zwischen den einzelnen Schritten (Quersummen-Stufen) wurde daher die Wandlung von Zahlen zu Ziffernketten notwendig, was zunächst mittels zwischengeschalteten Funktionen in Python-Code realisiert wurde. Das verhinderte aber eine durchgängige Multi-Layer-Modellstruktur.

Das wollen wir jetzt nachholen und damit dann diese Funktionen durch NN-Layer realisieren. Damit können dann die Modelle durchgängig als Multi-Layer-NN in keras/Tensorflow konstruiert werden — ohne “Medienbruch”. Ebenso können wir komplexere NN’s z.B. zur Klassifizierung von Vielfachen von 6 exemplarisch konstruieren und, soweit möglich, trainieren.

Die Notwendigkeit der Transformation von (ganzen) Zahlen in Ziffernketten begegnet uns immer wieder und es war zunächst nicht klar, wie man dies in einem NN abbilden kann. Wir zeigen das hier wieder für maximal 4-stellige Dezimalzahlen. Die Erweiterung auf höhere Stellenzahlen ist naheliegend, wurde aber hier nicht getestet. Mehr dazu weiter unten.

Für die Tests der nachfolgenden Modelle benötigen wir Trainings- und Testdaten mit jeweils aufgabenspezifischen Zielwerten. Die Generierung dieser Data Sets sowie die notwendigen imports stellen wir hier voran. Die Funktion to_digits() ist wie in 5.2 definiert.

# Imports and Data generation for models in #5.5

import numpy as np
import random as rd
import matplotlib.pyplot as plt
from keras.models import Sequential, Model
from keras.layers import Dense, Input, Lambda
from keras.optimizers import sgd, adam
from keras.initializers import Constant
from keras import backend as K

# Data
n_pos = 4
nmin,nmax = 0,10000 # nmax = Anzahl der Ziffern (0..9)
n_cases = 200
# Generate data set and convert to digits
X = np.random.randint(nmin,nmax,size=(n_cases))
y = to_digits_1(X,n_pos)
# First level checksums
z = np.sum(y,axis=1)
# 2nd level checksums; usually single digit
u = to_digits_1(z,n_pos)
v = np.sum(u,axis=1)

# Target values Layer Dense_Divide
y1 = np.array([w * x for x in X])
# Generate target classification multiple of 3
c3 = np.array(list(x%3 for x in X))
c3 = np.where(c3>0,1,c3) # Remainder > 0 are classified as c3 = 1


# Aufteilung in Traings- und Testdaten - simple partitioning
X_train = X[:150]
y_train = y[:150]
X_test = X[150:]
y_test = y[150:]
z_train = z[:150]
z_test = z[150:]
v_train = v[:150]
v_test = v[150:]
y1_train = y1[:150]
y1_test = y1[150:]
c3_train = c3[:150]
c3_test = c3[150:]

# Print examples ...
Daten und Targets - Beispiel:
Input Daten X: 9145
Target Ziffernkette y: [9 1 4 5]
Einfache Quersumme z: 19
2nd Level Quersumme v: 10
Target Dense_Divide y1: [ 9.145 91.45 914.5 9145. ]
Multiple of 3 class c3: 1

5.5.1 Eine NN-Struktur für die Transformation einer Zahl in eine Ziffernfolge

Der Übergang von einer (ganzen) Zahl a (hier < 10000) in eine Dezimalziffernkette x3 x2 x1 x0(hier der Länge 4) mit ggf. vorlaufenden Nullen kann wie folgt durch ein 3-schichtiges NN erreicht werden (Feed-Forward Modus):

In einer ersten Schicht wird a durch 4 Kernel-Gewichte w3, w2, w1, w0 zu einem 4er-Vektor z = [w3,w2,w1,w0]*a. Mit einer "custom" Aktivierungsfunktion wird z auf die ganzzahligen Anteile reduziert. In der zweiten Schicht werden diese durch eine 4x4 Gewichte-Matrix v so verrechnet, dass die Neuronen dieser Schicht nach einer weiteren "custom" Aktivierung nur noch die jeweilige Ziffer ausgeben. Insgesamt also a -> [y3,y2,y1,y0] (y0 die Einer-Stelle).

Mit dem keras API lässt sich diese Konstruktion als Tensorflow Modell definieren:

NN Modell-Architektur: Umwandeln Ganzzahl in Ziffernfolge

Mit den Custom-Aktivierungsfunktionen

# Custom activation functions

def custom_int(x):
return x - x%1
def custom_round(x):
return custom_int(x+0.5)

und den theoretisch ableitbaren Gewichten

ergibt sich folgende keras Spezifikation für das NN:

Zunächst die Custom-Aktivierungen und Kernel-Initializer (Gewichte) (Vorgabe-Gewichte müssen, ebenso wie die Aktivierungen, in keras als Tensor-fähige Funktion bereit gestellt werden.)

# Kernel initializers 

def div_init(shape, dtype=None):
a = np.array([1/1000,1/100,1/10,1])
# Disturb initial weights by eta
#eta = 0.0
#a = a + eta*np.array([1,-1,1,-1])*a
a = np.reshape(a,shape)
return a
def digit_init(shape, dtype=None):
a = np.array([[1,0,0,0],[-10,1,0,0],[0,-10,1,0],[0,0,-10,1]])
# Disturb initial weights by eta
#eta = 0.0
#a = (1+eta) * a
a = np.transpose(a)
return a

Anm.: Mit keras kann die Aktivierung auch als gesonderter Layer vom Typ Activation definiert werden und so die custom activation aus der Dense-Schicht ausgelagert werden.

Nun das Modell m1to4 (1 Zahl zu 4 Stellen). Modell-Summary und die Kernel-Weigths nach Compilation sowie ein Feed-Forward-Lauf mit einem Testdaten-Satz werden dargestellt. (Generierung der Daten hier nicht gezeigt, s.u.)

# Define model m1to4 that turns integers to digits (List of 4)
# Integers come as float datatype.
# (Change to int datatype, where required)
# Custom Activations 'custom_int' and 'custom_round'
# cannot be backpropagated.
# Model specificationinp = Input(shape=(1,),name='Input_iteger')
x = Dense(4,name = 'Dense_Divide',activation=custom_int,use_bias=False,
kernel_initializer=div_init,trainable=False)(inp)
out = Dense(4,name = 'Dense_Digits',activation=custom_round,use_bias=False,
kernel_initializer=digit_init,trainable=False)(x)
m1to4 = Model(inputs=[inp],outputs=[out])
m1to4.summary()
# Compilation
lrate= 0.01 # sgd with learning rate
opt = adam(lrate)
m1to4.compile(optimizer=opt,loss='mean_squared_error',metrics=['accuracy'])
print(m1to4.get_weights())# Feed Forward Evaluation
pred = m1to4.predict(X_test)
print(X_test[:10])
print(pred[:10])
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
Input_iteger (InputLayer) (None, 1) 0
_________________________________________________________________
Dense_Divide (Dense) (None, 4) 4
_________________________________________________________________
Dense_Digits (Dense) (None, 4) 16
=================================================================
Total params: 20
Trainable params: 0
Non-trainable params: 20
_________________________________________________________________
[array([[0.001, 0.01 , 0.1 , 1. ]], dtype=float32),
array([[ 1., -10., 0., 0.],
[ 0., 1., -10., 0.],
[ 0., 0., 1., -10.],
[ 0., 0., 0., 1.]], dtype=float32)]
[6994 5296 1378 6499 1911 8894 7375 8264 33 5455]
[[6. 9. 9. 4.]
[5. 2. 9. 6.]
[1. 3. 7. 8.]
[6. 4. 9. 9.]
[1. 9. 1. 1.]
[8. 8. 9. 4.]
[7. 3. 7. 5.]
[8. 2. 6. 4.]
[0. 0. 3. 3.]
[5. 4. 5. 5.]]

Man erkennt, dass das so definierte dreischichtige NN die Aufgabe löst, eine Zahl in ihre Ziffern zu zerlegen. Die Erweiterung auf mehr Stellen ist — theoretisch — offensichtlich. Aus Sicht Machine Learning kommt hier allerdings die nicht zu vernachlässigenden Problematik der unterschiedlichen Größenordnungen der Target-Gewichte ins Spiel: 1, 1/10, 1/100, 1/1000 usw.

Trainierbarkeit des Modells

Das Modell ist nicht trainierbar! Das war aufgrund der besonderen Aktivierungsfunktionen zu erwarten: int() bzw round() sind Stufenfunktionen, d.h. nicht stetig und haben darüber hinaus bis auf die Sprungpunkte die Ableitung 0. Damit gibt es beim Back-Propagating keine "iterierbaren" Gradienten. Mit m1to4.fit() arbeitet es sich zwar brav durch die Epochen, der loss-Wert bleibt aber i.w. gleich hoch. (0.0, wenn man mit den theoretischen Gewichten startet).

Man kann versuchen, die Layer einzeln zu trainieren, z.B. nur den ersten Dense Layer (Dense_Divide). Das gelingt, wenn man die activation = 'linear' setzt, mit der Custom Activation stößt das Back-Propagating auf das gleiche Problem. Ohne dies hier näher zu dokumentieren, sind die Layer für sich mit geeigneten Startgewichten - sogar mit Default-Startgewichten - trainierbar, wenn die Aktivierungsfunktion z.B. 'linear' gewählt wird. Allerdings sind sie dann nicht zielführend zusammenfügbar.

In Teil 6 werden wir uns an Beispielen zur “Universellen Approximations-Eigenschaft von NNs” eingehender mit der Frage der Trainierbarkeit befassen. Unter anderem auch mit einem Versuch, die problematischen Custom-Aktivierungsfunktionen selbst wieder durch ein NN zu ersetzen (zu approximieren).

Was nützt das Modell, wenn es nicht trainierbar ist? Nun, mit den richtigen Gewichten ist m1to4 ein mustergültiges NN "im trainierten Zustand", das die Konvertierung von Zahlen in Ziffernfolgen leistet. Es kann damit im Sinne von Transfer Learning in weiterführenden NN-Architekturen eingestzt werden, die für ihre Lernaufgaben diese Transformation benötigen. Wir werden das an den folgenden Beispielen darstellen. Aus algorithmischer Sicht haben wir mit m1to4 eine Repräsentation des Zahl-zu-Ziffernfolge Algorithmus durch ein NN-Modell, alternativ zu verschiedenen anderen Möglichkeiten der Formulierung - wie etwa unserer in Python formulierte Funktion to_digits_1(X,n_pos).

5.5.2 Das Modell für die einfache Quersumme

Mit dem dreischichtigen Modell für die Konvertierung Zahl zu Ziffernfolge können wir das Quersummen-Modell aus 5.2 konsistent nachbilden ohne die Trainingsdaten vorher zu konvertieren.

NN Modell-Architektur: Einfache Quersumme

Neu hinzu gekommen ist der Dense_sum Layer mit linearer Aktivierung.

Das Modell mqs1 (Quersumme 1. Stufe) ist im letzten Layer (Dense_sum) ohne vorgegebene Gewichte und als trainierbar deklariert. Die Layer davor sind im "trainierten Zustand" fixiert, wie in 5.5.1 erklärt. Es nimmt ganze Zahlen als Input und liefert (im trainierten Zustand) die erste Quersumme.

# Define NN-model that takes an integer, turns it to 
# digits (List of 4) and
# Calculates the first level checksum (sum of digits)
inp = Input(shape=(1,),name='Input_integer')
# First level checksum
x = Dense(4,name = 'Dense_Divide',activation=custom_int,use_bias=False,
kernel_initializer=div_init,trainable=False)(inp)
x = Dense(4,name = 'Dense_Digits',activation=custom_round,use_bias=False,
kernel_initializer=digit_init,trainable=False)(x)
out = Dense(1,name='Dense_sum',activation='linear',
use_bias=False,trainable=True)(x)
mqs1 = Model(inputs=[inp],outputs=[out])
mqs1.summary()
# Compilation
lrate= 0.01 # learning rate
opt = adam(lrate)
mqs1.compile(optimizer=opt,loss='mean_squared_error',metrics=['accuracy'])
print(mqs1.get_weights())# Feed-Forward Evaluation
pred = mqs1.predict(X_test)
print(X_test[:])
print(pred[:])
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
Input_iteger (InputLayer) (None, 1) 0
_________________________________________________________________
Dense_Divide (Dense) (None, 4) 4
_________________________________________________________________
Dense_Digits (Dense) (None, 4) 16
_________________________________________________________________
Dense_sum (Dense) (None, 1) 4
=================================================================
Total params: 24
Trainable params: 4
Non-trainable params: 20
_________________________________________________________________
[array([[0.001, 0.01 , 0.1 , 1. ]], dtype=float32),
array([[ 1., -10., 0., 0.],
[ 0., 1., -10., 0.],
[ 0., 0., 1., -10.],
[ 0., 0., 0., 1.]], dtype=float32),
array([[ 0.60630083],
[-0.6464857 ],
[ 0.29475605],
[ 0.00460315]], dtype=float32)]
[4167 4347 5039 8435 7451 7837 3232 449 3167 815]
[[ 3.579476 ]
[ 1.6969925 ]
[ 3.9572008 ]
[ 3.171748 ]
[ 3.1365464 ]
[-0.01128948]
[ 1.4194056 ]
[-1.3654902 ]
[ 2.9731753 ]
[-4.8541136 ]]

Die Gewichte für den Dense_sum Layer sind initial per Default gesetzt (s. Keras Dokumentation), folglich zeigt Output zunächste ein zufälliges Bild. Alternativ können die Anfangswerte der Gewichte wieder mit einer Kernel-Initializer Funktion gesetzt werden, z.B. als "Störung" der theoretischen Werte.

Training des Modells

Die Trainierbarkeit ist in der Modell-Spezifikation effektiv auf den letzten Layer beschränkt. Als Trainingsdaten verwenden wir wieder ein Set von bis zu vierstelligen ganzen Zahlen X_train. Die Zielergebnisse (einfache Quersumme) sind zum Training in z_train vorgegeben.

# Now try training of the first level checksum - 
# only out_dense is trainable.
# z_train has the target values for 1st level checksums
ep = 100 # Set number of epochs
bs = 10 # Set batch size
hist1 = mqs1.fit(X_train,z_train,epochs=ep,batch_size=bs,verbose=0)
print([hist1.history['loss'][i] for i in range(0,ep,5)])
[13.760152180989584, 1.2034216105937958, 0.15252007246017457, 0.009247574225688974, 0.00024391114954293395, 2.8738677049962766e-06, 1.2348405113253117e-08, 1.7086373823328264e-11, 1.2922403786498153e-11, 1.2716251807330195e-11, 1.2716251720594022e-11, 1.019543558374488e-11, 1.0145413271063959e-11, 7.647334559583256e-12, 6.361915508290572e-12, 6.3619154504664566e-12, 6.361915421554398e-12, 6.361915479378514e-12, 4.289783386730398e-12, 4.289783415642456e-12]

Die Loss- Entwicklung zeigt schöne Konvergenz, ab etwa Epoche 30 ist das Modell, bzw. der Dense_sum Layer, austrainiert. Die trainierten Gewichte und die Evaluation mit dem Testdaten-Set entsprechen jetzt den Erwartungen:

# Evaluate
print(mqs1.get_weights())

pred = mqs1.predict(X_test)
print(X_test[:10])
print(pred[:10])
[array([[0.001, 0.01 , 0.1 , 1. ]], dtype=float32),
array([[ 1., -10., 0., 0.],
[ 0., 1., -10., 0.],
[ 0., 0., 1., -10.],
[ 0., 0., 0., 1.]], dtype=float32),
array([[1.0000025],
[0.999709 ],
[1.0000718],
[1.0002054]], dtype=float32)]
[4167 4347 5039 8435 7451 7837 3232 449 3167 815]
[[18.001587]
[18.000862]
[17.002075]
[20.0001 ]
[16.999418]
[24.999344]
[10.000052]
[17.00097 ]
[17.001585]
[13.998771]]

Der Output aus dem letzten Layer (Dense_sum) kommt den Zielwerten offensichtlich "beliebig" nahe, kommt aber als Gleitkommazahlen (float). Ebenso kommen die trainierten Gewichte der letzten Schicht den theoretischen Werten (alles 1.0) beliebig nahe. Sofern dieses NN hiermit seine Aufgabe erfüllt hat, ist das Ergebnis perfekt - Ein (float) Ergebnis von z.B. 16.9994 wird als Quersumme 17 “interpretiert”.

Für die weiteren Stufen der Quersummen, z.B. in einem vollständigen NN-Modell zur Klassifikation von Vielfachen von 3, ist der (float) Output aber unter Umständen problematisch. Für die nächste Stufe der Quersumme geht dieser Output als Input in die nächste, gleich aufgebaute Layer-Gruppe (ohne den Input Layer) über. Diese erwartet, idealerweise, Ganzzahlen als Input; bei float Zahlen als Input wird nur der ganzzahlige-Anteil weiter genutzt, was bei einem Input von z.B. 16.9994 zu einem falschen Ergbenis führt. (Die Möglichkeit eines zusätzlich zwischengeschalten Layers prüfen wir im nächsten Abschnitt.)

Wir werden im folgenden Abschnitt (5.6) das komplette NN-Modell (quasi in RNN-artiger Architektur) aus hintereinander geschalteten “Quersummen-Layer-Gruppen” aufbauen. Dabei werden wir wegen der verschiedenen back propagation Blockaden (durch unstetige Aktivierungsfunktionen) ohnenhin eine transfer learning Strategie verwenden müssen: wir setzen die back-tracking-blockierten Layer im (vor-)trainierten Zustand ein und nehmen sie aus dem Training aus.

Anm.: Grundsätzlich kann man sich fragen, ob deartig zweck-bestimmte und nicht mehr insgesamt trainierbare NN-Strukturen sinnvolle ML-Modelle sind, oder “nur” feste algorithmische Schritte implementieren, die genauso gut auf andere Weise formuliert sein könnten (wie z.B. Zahl->Ziffernkette mit der Funtkion to-digits() in 5.2). Mit diesem Dilemma werden wir uns in piece #6 noch auseinander setzen: Trainierbar ("able to learn") oder Trainiert ("able to act"). Insofern bekommt meine Frage am Anfang (in 1.) eine neue Bedeutung.

Was man zumindest sagen kann, ist, dass man auch für solche Aufgaben wie Zahl->Ziffern Neuronale Netze konstruieren kann, aus denen man wiederum NN’s für komplexere Aufgaben konstruieren und teilweise trainieren kann. Und, es ist nicht kontra-intuitiv sich vorzustellen, dass Teilschritte (wie im Dense_Divide Layer definiert) zunächst erlernt werden und danach als gelernte Fähigkeit (unverändert) eingesetzt werden.

Weiter lesen: 5.6 Das NN-Modell zur Klassifikation von Vielfachen von 3 als vollständiges Deep NN

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