Bernd Thomas
Jan 14 · 11 min read

6.3 Pythagoras Lernen

pixabay

Der Satz von Pythagoras hat viele Schülergenerationen gequält. Ein guter Grund also, auch unser “universelles” Neuronales Netzwerk einmal damit zu befassen. Numerisch läuft der Satz darauf hinaus, die Länge c der Hypothenuse (längste Seite) in einem rechtwinkligen Dreieck aus den Längen a und b der Katheten (Winkelseiten) zu bestimmen nach der Formel c**2 = a**2 + b**2.

Als Approximationsaufgabe bedeutet das, die Funktion y = srqt(a**2+b**2) durch ein NN zu approximieren. Da wir gesehen haben, dass sich die Wurzelfunktion und Quadratfunktion gut approximieren lassen (mit hinreichend Trainingsdaten und Neuronen im Hidden Layer), könnten wir hier versuchen, statt eines "flachen" NN ein strukturiertes NN zu konstruieren. Mit keras lassen sich zwei Teilmodelle für die beide Quadrate über einen Add-Layer mit einem Teilmodell für die Quadratwurzel verbinden. Dabei sind die jeweiligen Teilmodelle gleich, was einem modularen Aufbau eines strukturierten NN durch eine "universelle" Komponente entspricht.

Das modulare NN-Modell

Das keras-Modell hat zwei Input-Streams (für a und b), die in die beiden Standard-NN eingehen. Deren Output wird addiert und geht als Input in ein weiteres Standard-NN mit dem finalen Output. (Anm.: Statt des Add Layers könnte man die beiden Output-Streams auch über einen Concatenate-Layer zusammenführen und die "Addition" in einer eigenen Dense-Schicht ebenfalls in das Training einbeziehen.)

Die Standard-Teilmodelle haben für die Neuronen-Anzahl des Hidden Layers den Hyper-Paramter n_nodes, der hier für alle gleichermaßen auf 64 gesetzt ist. Es ist damit leicht, Modell-Varianten mit anderen bzw. unterschiedlichen Neuronenanzahlen zu testen.

# keras model for approximation of the rule of Pythagorasfrom keras.models import Sequential, Model
from keras.layers import Dense,Add,Input
inp0 = Input(shape=(1,))
inp1 = Input(shape=(1,))
n_nodes = 64x0 = Dense(n_nodes, activation='sigmoid')(inp0)
out0 = Dense(1, activation='linear')(x0)
x1 = Dense(n_nodes, activation='sigmoid')(inp1)
out1 = Dense(1, activation='linear')(x1)
z = Add()([out0,out1])z = Dense(n_nodes, activation='sigmoid')(z)
out = Dense(1, activation='linear')(z)
mf = Model(inputs=[inp0,inp1], outputs =[out])
# Note: mf has 2 input streams and 1 output
mf.summary()sgd1 = sgd(lr= 0.001) # sgd with learning rate
mf.compile(optimizer=sgd1,loss='mean_squared_error',
metrics=['accuracy'])
____________________________________________________________________
Layer (type) Output Shape Param # Connected to
====================================================================
input_1 (InputLayer) (None, 1) 0
____________________________________________________________________
input_2 (InputLayer) (None, 1) 0
____________________________________________________________________
dense_1 (Dense) (None, 64) 128 input_1[0][0]
____________________________________________________________________
dense_3 (Dense) (None, 64) 128 input_2[0][0]
____________________________________________________________________
dense_2 (Dense) (None, 1) 65 dense_1[0][0]
____________________________________________________________________
dense_4 (Dense) (None, 1) 65 dense_3[0][0]
____________________________________________________________________
add_1 (Add) (None, 1) 0 dense_2[0][0] dense_4[0][0]
____________________________________________________________________
dense_5 (Dense) (None, 64) 128 add_1[0][0]
____________________________________________________________________
dense_6 (Dense) (None, 1) 65 dense_5[0][0]
====================================================================
Total params: 579
Trainable params: 579
Non-trainable params: 0

Für die initialen Gewichte (Kernel und Bias) werden keine besonderen Vorgaben gemacht, der Optimizer bleibt wie bei den Modellen in 6.1, 6.2 zuvor.

Trainingsdaten

Als Trainingsdaten wird eine Zufallsauswahl von hier 30% (Parameter p) des gesamten Definitionsbereichs getroffen, hier das Gitter [0.0,10.0]x[0.0,10.0] in Schritten von 1/10. Hierfür wird der "wahre" Funktionswert als Zielvorgabe berechnet.

# Data generation for Pythagoras model# Imports as in the code parts for the functions in 6.2, 6.3 above
# Define function over [0,10]x[0,10] in steps of 1/10
A = [i/10 for i in range(0,100)]
B = [i/10 for i in range(0,100)]
D = list()
y = list()
for a in A:
for b in B:
D.append([a,b])
D = np.array(D)
print(D.shape)
n_val = D.shape[0] # Total function definition points
p = 0.3 # 30% of function data ...
n_cases = int(n_val*p) # ... used for training
print(n_val, p, n_cases)
# Random selection of n_cases training data
ind = np.random.choice(n_val,n_cases)
X = D[ind]
# Generate function values for selection
for i in range(n_cases):
y.append(np.sqrt(X[i][0]**2 + X[i][1]**2))
y = np.array(y)
print(X.shape[0],y.shape[0])
# Split columns for input streams a and b
X0 = X[:,0]
X1 = X[:,1]
print(X0.shape,X1.shape,y.shape)
(10000, 2)
10000 0.3 3000
3000 3000
(3000,) (3000,) (3000,)

Das Training lassen wir über 400 Epochen gehen, da sich zeigt, dass sich bis dahin immer noch leichte loss -Verbesserungen ergeben (Bsp.: von initial 9.0 über 0.09, 0.06 auf 0.043 (in Schritten von 100).

# Training of mfne = 400          # Epochs
h_step = 20 # Loss output steps
bs = 5 # Batch size
hist1 = mf.fit([X0,X1],y,epochs=ne,batch_size=bs,verbose=0)
# Note: 2 Input tensors X0, X1

Zur Evaluation wählen wir zufällige Testpunkte aus. Die Anzahl setzen wir auf 10% des Trainingsumfangs.

n_test = int(n_cases*0.1)

ind = np.random.choice(n_val,n_test)
X = D[ind]
T0 = X_test[:,0]
T1 = X_test[:,1]
y_pred = mf.predict([T0,T1])
# Evaluation output code not shown

Ergebnisse

Um die Güte der Approximation einzuschätzen, sollen hier nur jeweils die ersten 10 Beispiele aus den Trainingsdaten (X0,X1) und den Testdaten (T0,T1) gezeigt werden. (Den Code für die Ausgabe geben wir hier nicht wieder.)

Die ersten 10 Ergebnisse mit Trainingsdaten
a b c y_pred
------------------------------
9.5 7.6 12.1659 12.1373
5.0 6.3 8.0430 8.2522
7.8 9.1 11.9854 12.0133
6.0 9.8 11.4909 11.4352
5.2 6.0 7.9398 8.1481
4.2 1.9 4.6098 4.4886
8.7 0.9 8.7464 8.7050
2.0 6.0 6.3246 6.3277
1.5 5.9 6.0877 6.0391
7.9 3.0 8.4504 8.5799
Die ersten 10 Ergebnisse aus 300 Testdaten
a b c y_pred
------------------------------
3.9 0.3 3.9115 3.5479
6.9 7.2 9.9725 10.2659
4.4 9.4 10.3788 10.3591
7.6 5.1 9.1526 9.3928
6.2 4.6 7.7201 7.9030
1.2 8.1 8.1884 8.1744
6.2 6.9 9.2763 9.5681
4.4 0.3 4.4102 4.0419
7.0 8.2 10.7815 10.9991
4.2 7.7 8.7710 8.9374

Der Vergleich der “wahren” Werte (c) mit den "predicted" Werten (y_pred) zeigt tatsächlich eine sehr gute Annäherung so wohl für die Trainingsdaten als auch für die Testdaten, die nachträglich per Zufall erzeugt wurden.

Pythagoräische Zahlentripel

Wir wollen nun sehen, wie gut ganzzahlige pythagoräische Tripel gefunden werden, also etwa c=5 für a=3, b=4. Dazu geben wir der predict() Methode des trainierten Modells die a's und b's für einige bekannte Beispiele vor, z.B. mit

A = [3,6, 5, 8, 9,12]
B = [4,8,12,15,12,16]

Als Ergebnis bekommen wir:

Einige pythagoräische Zahlentripel
a b c y_pred y_round
------------------------------------
3.0 4.0 5.0 4.8878 5.0
6.0 8.0 10.0 10.2368 10.0
5.0 12.0 13.0 12.2096 12.0
8.0 15.0 17.0 14.4992 14.0
9.0 12.0 15.0 13.9132 14.0
12.0 16.0 20.0 15.7470 16.0

Das sieht außer für die ersten beiden Tripel gar nicht gut aus!

Nach den bisherigen Erfahrungen ahnt man schon, woran das liegt: Das NN interpoliert recht gut, selbst mit nur 30% der Punkte im Interpolationsbereich. Mit Werten für a oder b über 10 hinaus, können wir jedoch keine Genauigkeit erwarten. Aber ...

Weitere Experimente und Erkenntnisse

A. Skalierbarkeit

Es gibt allerdings einen im ML-Kontext beliebten Trick, den wir hier angepasst anwenden können: eine Skalierung der Daten. Glücklicherweise kann man bei unserem Pythagoras-Problem die Werte von a und b durch die gleiche Zahl (sagen wir s) teilen, so dass die Werte wieder im Definitionsbereich des Trainings liegen. Die prediction y_pred für diese Werte ist daher so genau, wie wir das durch das Training erzielt haben. Um die Antwort auf die eigentlichen Werte a und b zu bekommen, muß y_pred nur wieder mit s multipliziert werden.

Bei dem Beispiel der pythagoräischen Tripel, oben, kommen wir z.B. mit s = 2 in den "Trainingsbereich". Das predict-Ergebnis (nach Mutliplikation mit s) sieht dann wie folgt aus:

Einige pythagoräische Zahlentripel (skaliert)
a b c y_pred y_round
------------------------------------
3.0 4.0 5.0 4.8878 5.0
6.0 8.0 10.0 10.2368 10.0
5.0 12.0 13.0 13.0621 13.0
8.0 15.0 17.0 17.3233 17.0
9.0 12.0 15.0 15.3044 15.0
12.0 16.0 20.0 20.4737 20.0

Das sieht deutlich besser aus!

Die Repräsentation des Inputs durch Skalierung in dieser Form ist eine Eigenschaft der Aufgabe und funktioniert nicht immer. Sie ist daher mit Vorsicht einzusetzen, will man nicht das Risiko eingehen, dass man Eigenschaften der Lösung bereits in die Daten steckt — im Teil 1 hatten wir den sin**2 Trick als Präkonditionierung eingesetzt und dann als "Fake" für die Gerade/Ungerade-Aufgabe erkannt.

Bei den Beispielen Wurzelfuntkion und Quadrate z.B. ist die Skalierungsidee nicht anwendbar — oder zumindest nicht akzeptabel: Will man einen außerhalb des Interpolationsbereichs liegenden Inputwert x durch Skalierung mit s passend machen, muss anschließend das Ergebnis mit sqrt(s) bzw. s**2 rücktransformiert werden (per Multiplikation). Das bedeutet aber, dass man das, was das NN "lernen" soll, bereits außerhalb des NN (zur Konditionierung) verwenden muss. Wir werden dem beim "Multiplikationsproblem" in 6.4 wieder begegnen.

B. Bedeutung der Modell-Struktur

Wir haben das mehrschichtige NN-Modell nach der Struktur der Aufgabe aufgebaut: Quadrieren der beiden Inputgrößen, Addition und Quadratwurzel daraus.

Als Trainingsdaten haben wir lediglich die Input-Zahlen a und b vorgegeben und das Endergebnis c. Und im Unterschied zum Modell für das Lernen von Vielfachen von 6 in 5.7 können wir nicht sagen, dass die Teilmodelle vor und nach dem Add Layer eine bestimmte Teil-Funktion abbilden. Die Layer sind alle gleichartig.

Woher “weiß” dann das Modell, dass es in den beiden parallelen Zweigen die Qudrate von a und b berechnen soll?

Wir können “nachsehen”, ob es das wirklich tut, indem wir die Gewichts-Tensoren des trainierten Gesamtmodells mf sichern (mit w = mf.layer.get_weight() für die 4 Layer), zwei Modelle mfA und mfB generieren, die jeweils aus den beiden Schichten vor dem Add Layer bestehen, und ihnen mit

mfA.layers[1].set_weights(A0_w)
mfA.layers[2].set_weights(A1_w)
mfB.layers[1].set_weights(B0_w)
mfB.layers[2].set_weights(B1_w)

die trainierten Gewichte zuweisen. Die predictions für jeweils einen Input-Stream müssten dann - erwartungsgemäß - Näherungen für die Quadrate ergeben.

Aber nichts dergleichen passiert!

# Prediction of branch A of trained modely_pred = mfA.predict(X0)for i in range(10):
a = X0[i]
print("%4.1f %8.4f %8.4f" %(a,a**2,y_pred[i][0]))
a a**2 y_pred
------------------------
6.4 40.9600 1.9168
0.5 0.2500 0.5862
3.7 13.6900 1.0561
8.5 72.2500 2.7863
1.1 1.2100 0.6437
4.4 19.3600 1.2383
1.6 2.5600 0.6973
4.9 24.0100 1.3877
0.1 0.0100 0.5485
4.5 20.2500 1.2670

Anders ausgedrückt, das jeweilige Teilmodell “weiß nicht”, welche “Rolle” es in der Lernaufgabe hat. Und es schert sich nicht um die so gut ausgedachte (Berechnungs-)Struktur. Das Modell mf lernt, was es lernen soll aus Input und Vorgabewerten - wie es dahin kommt ist "seine Sache". Es bildet insbesondere nicht den vorgedachten Berechnungsalgorithmus aus.

Hier unterscheidet sich wohl das NN von einer Schülerin: Die Schülerin lernt Quadrate und Quadratwurzeln und wendet dies an, um die Formel für die Länge der Hypothenuse auszuwerten. Das NN, direkt mit den Trainingsdaten versehen, lernt die komplette Aufgabe “frei”, trotz Strukturvorgabe. Die Lösung, in Form von trainierten Gewichten, hängt dabei eher von den Startwerten ab.

C. Transfer Learning

Mit Transfer Learning bezeichnet man generell die Verwendung von trainierten Modellen für andere oder komplexere Aufgaben — in der Regel, um den Trainingsaufwand zu reduzieren. Das entspricht gut dem Vorgehen der Schülerin.

Wir haben in 6.2 ein NN für Quadratwurzel und Quadrate trainiert. Der trainierte Zustand manifestiert sich in den Endwerten für die Gewichte des Modells. keras/tensorflow erlaubt es, diese in ein neues Modell zu transferieren: w = model.layers[i].get_weights() liefert die die Gewichte des i-ten Layers von model. Und new_model.layers[j].set_weights(w) setzt w als die Gewichte des k-ten Layers von new_model. (Natürlich müssen die Gewichtstensoren passend sein.)

Wir probieren das mit den beiden Modell-Komponenten vor dem AddLayer, indem wir zwei NNs mfAund mfB erstellen und diese nach der Initialisierung mit den Gewichtstensoren aus dem auf Quadrate trainierten Modell in 6.2 versehen. Natürlich muß das vorausgegangene Training an einer Modellvariante mit 64 Neuronen (im Hidden Layer) erfolgt sein, da wir hier mit dieser Anzahl arbeiten.

from keras.models import Sequential, Model
from keras.layers import Dense,Add, Input
# Sub-models A, B: set trained weights, make predictionsinp0 = Input(shape=(1,))
inp1 = Input(shape=(1,))
n_nodes = 64x0 = Dense(n_nodes, activation='sigmoid')(inp0)
out0 = Dense(1, activation='linear')(x0)
x1 = Dense(n_nodes, activation='sigmoid')(inp1)
out1 = Dense(1, activation='linear')(x1)
mfA = Model(inp0,out0)
mfB = Model(inp1,out1)
mfA.summary()
mfB.summary()
sgd1 = sgd(lr= 0.001)
mfA.compile(optimizer=sgd1,loss='mean_squared_error',
metrics=['accuracy'])
mfB.compile(optimizer=sgd1,loss='mean_squared_error',
metrics=['accuracy'])
# Transfer of weights w_sqr_ from trained NN (64) for Squares
mfA.layers[1].set_weights(w_sqr_0)
mfA.layers[2].set_weights(w_sqr_1)
mfB.layers[1].set_weights(w_sqr_0)
mfB.layers[2].set_weights(w_sqr_1)
# Layer numbering starts at 0 (for Input Layer)_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) (None, 1) 0
_________________________________________________________________
dense_7 (Dense) (None, 64) 128
_________________________________________________________________
dense_8 (Dense) (None, 1) 65
=================================================================
Total params: 193
Trainable params: 193
Non-trainable params: 0
_________________________________________________________________
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_2 (InputLayer) (None, 1) 0
_________________________________________________________________
dense_9 (Dense) (None, 64) 128
_________________________________________________________________
dense_10 (Dense) (None, 1) 65
=================================================================
Total params: 193
Trainable params: 193
Non-trainable params: 0

Anm.: In keras kann man die Layer-weise Gewichtsübertragung vereinfachen, wenn man ein komplettes Modell transferieren will. Man verwendet dann W = model.get_weights() und new_model.set_weights(W).

Die Evaluation beider Modelle mit dem einfachen Input Datenstrom zeigt deutlich, dass mfA(und mfB) nun tatsächlich eher Quadrate des Inputs berechnen. (Die offensichtlichen Abweichung erklären sich i.W. aus der Tatsache, dass wir in 6.2 über ein anderes "Gitter" trainiert hatten ([0,100 in Schritten von 0.1).

y_pred = mfA.predict(X1)
for i in range(10):
a = X1[i]
print("%4.1f %8.4f %8.4f" %
(a,a**2,y_pred[i][0]))
a a**2 y_pred
------------------------
1.7 2.8900 3.9286
6.6 43.5600 47.8360
1.2 1.4400 2.8479
5.7 32.4900 34.2710
8.0 64.0000 69.6591
1.1 1.2100 2.6775
7.1 50.4100 55.8292
5.6 31.3600 32.8886
2.0 4.0000 4.7736
6.2 38.4400 41.5987

D. Das “Pythagoras-NN” vollständig algorithmisiert

Schließlich können wir auch die beiden Layer nach dem Add durch das trainierte Quadratwurzel-NN aus 6.2 ersetzen und damit ein NN für die Pythagoras-Aufgabe erstellen, das in allen Gewichten "vortrainiert" ist. keras bietet hier die Möglichkeit, trainierte NN komplett als Layer einzusetzen. Damit sieht das Pythagoras Modell dann so aus:

# Full model again - using trained models for square and sqrtn_nodes = 64inp0 = Input(shape=(1,))
inp1 = Input(shape=(1,))
out0 = mf_sqr(inp0)
out1 = mf_sqr(inp1)
z = Add()([out0,out1])
out = mf_sqrt(z)
mf = Model(inputs=[inp0,inp1], outputs =[out])

In dieser Modell-Version ist natürlich kein Training mehr nötig (und möglich), da alle Gewichte “gesetzt” sind. Es ist also wieder nur mit predict() zu prüfen, ob das Modell seine Aufgabe erledigt.

y_pred = mf.predict([X0,X1])
for i in range(10):
a,b = X0[i],X1[i]
print("%4.1f %4.1f %8.4f %8.4f" %
(a,b,np.sqrt(a**2+b**2),y_pred[i][0]))

a b c y_pred
----------------------------
7.1 1.7 7.3007 7.7647
6.5 6.6 9.2634 9.6377
0.5 1.2 1.3000 2.1040
9.6 5.7 11.1647 10.6035
5.0 8.0 9.4340 9.6738
8.8 1.1 8.8685 9.1154
6.5 7.1 9.6260 9.9566
3.9 5.6 6.8242 6.8890
3.6 2.0 4.1183 4.1875
7.9 6.2 10.0424 10.2280

Unter Berücksichtigung der oben erwähnten Abweichung also ein gutes Ergebnis, wobei die Berechnung nun erzwungenermaßen der Pythagoras-Formel folgt.

Eine weitere Möglichkeit, die Berechnung nach der Formel zu trainieren besteht darin, auxilliary outputs für spezifische Layer zu definieren. Den Output der beiden “Zweige” vor dem Add Layer, out0 und out1 nimmt man dazu in die outputs Liste des Modells auf. Für das Training, in der fit Funktion, müssen dann Zielwerte auch für die auxilliary outputs vorgegeben werden, also die Quadrate der Input-Streams. keras unterstützt das recht einfach mit seiner API.

E. Pythagoras “flach”

Ohne Vorgabe von Randbedinungen wirkt sich die Strukturierung das NN-Modells offenbar kaum bis gar nicht aus. Also könnte man ebenso gut ein “flaches” Modell definieren und trainieren. Wir verwenden dazu den NN-Ansatz wie in 6.1 und 6.2.

Da wir wissen, dass die Größe der Schichten Einfluß auf die Approximationgüte hat, setzen fairerweise die Neuronenzahl n_nodes im Hidden Layer so an, dass das flache Modell über etwa so viele trainierbare Parameter verfügt wie das strukturierte.

# A flat model for the Pythagoras taskn_nodes = 2*64 + 17mf = Sequential()
mf.add(Dense(n_nodes, activation='sigmoid',input_shape=(2,)))
mf.add(Dense(1, activation='linear'))
mf.summary()sgd1 = sgd(lr= 0.001)
mf.compile(optimizer=sgd1,loss='mean_squared_error',metrics=['accuracy'])
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense_37 (Dense) (None, 145) 435
_________________________________________________________________
dense_38 (Dense) (None, 1) 146
=================================================================
Total params: 581
Trainable params: 581
Non-trainable params: 0

Mit gleicher Trainingsdaten-Menge zeigt sich, dass die Konvergenz sogar besser ist als beim struturierten Modell. Das Training lassen wir hier nur über 300 Epochen gehen, wobei sich der Fehler (loss, in Schritten von 100) von initital 3.2 über 0.013 und 0.009 auf 0.007 reduziert - im Vergleich zu rund 0.04 nach 400 Epochen beim strukturierten Modell.

Die gleichen Testauswertungen wie oben ergeben hier eher noch bessere Genauigkiet:

Die ersten 10 Ergebnisse mit Trainingsdaten
a b c y_pred
------------------------------
7.1 1.7 7.3007 7.3756
6.5 6.6 9.2634 9.4047
0.5 1.2 1.3000 1.5046
9.6 5.7 11.1647 11.1244
5.0 8.0 9.4340 9.4985
8.8 1.1 8.8685 8.9338
6.5 7.1 9.6260 9.7643
3.9 5.6 6.8242 6.8502
3.6 2.0 4.1183 4.0143
7.9 6.2 10.0424 10.1441
Die ersten 10 Ergebnisse aus 300 Testdaten
a b c y_pred
------------------------------
9.1 6.8 11.3600 11.3722
3.1 7.0 7.6557 7.6891
2.2 2.2 3.1113 2.9981
8.9 5.9 10.6780 10.7038
1.6 4.5 4.7760 4.7503
1.2 6.7 6.8066 6.9000
8.9 7.3 11.5109 11.5396
0.2 0.9 0.9220 1.2351
2.0 1.8 2.6907 2.6092
9.5 2.6 9.8494 9.8461
Einige pythagoräische Zahlentripel
a b c y_pred y_round
------------------------------------
3.0 4.0 5.0 4.9206 5.0
6.0 8.0 10.0 10.0960 10.0
5.0 12.0 13.0 12.6534 13.0
8.0 15.0 17.0 15.6424 16.0
9.0 12.0 15.0 14.4179 14.0
12.0 16.0 20.0 17.6127 18.0
Einige pythagoräische Zahlentripel (skaliert)
a b c y_pred y_round
------------------------------------
3.0 4.0 5.0 4.9206 5.0
6.0 8.0 10.0 10.0960 10.0
5.0 12.0 13.0 13.0218 13.0
8.0 15.0 17.0 17.0971 17.0
9.0 12.0 15.0 15.1319 15.0
12.0 16.0 20.0 20.1920 20.0

Weiter lesen: 6.4 Multiplizieren Lernen — problematisch für NN

Zurück auf Anfang

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

Beck et al.

*Arbeiten ist Zusammenarbeiten*. Von Menschen, Daten, Infrastrukturen. Dafür stehen wir.

Bernd Thomas

Written by

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

Beck et al.

*Arbeiten ist Zusammenarbeiten*. Von Menschen, Daten, Infrastrukturen. Dafür stehen wir.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade