In my last article in this blog I wrote a bit about some steps to get Keras running with Tensorflow 2 [TF2] and Cuda 10.2 on Opensuse Leap 15.1. One objective of these efforts was a performance comparison between two similar Multilayer Perceptrons [MLP] :

- my own MLP programmed with Python and Numpy; I have discuss this program in another article series;
- an MLP with a similar setup based on Keras and TF2

Not for reasons of a competition, but to learn a bit about differences. When and for what parameters do Keras/TF2 offer a better performance?

Another objective is to test TF-alternatives to Numpy functions and possible performance gains.

For the Python code of my own MLP see the article series starting with the following post:

A simple Python program for an ANN to cover the MNIST dataset – I – a starting point

But I will discuss relevant code fragments also here when needed.

I think, performance is always an interesting topic – especially for dummies as me regarding Python. After some trials and errors I decided to discuss some of my experiences with MLP performance and optimization options in a separate series of the section “Machine learning” in this blog. This articles starts with two simple measures.

# A factor of 6 turns turns into a factor below 2

Well, what did a first comparison give me? Regarding CPU time I got a **factor of 6** on the MNIST dataset for a batch-size of 500. Of course, Keras with TF2 was faster 🙂 . Devastating? Not at all … After years of dealing with databases and factors of up to 100 by changes of SQL-statements and indexing a factor of 6 cannot shock or surprise me.

The Python code was the product of an unpaid hobby activity in my scarce free time. And I am still a beginner in Python. The code was also totally unoptimized, yet – both regarding technical aspects and the general handling of forward and backward propagation. It also contained and still contains a lot of superfluous statements for testing. Actually, I had expected an even bigger factor.

In addition, some things between Keras and my Python programs are not directly comparable as I only use 4 CPU cores for Openblas – this gave me an optimum for Python/Numpy programs in a Jupyter environment. Keras and TF2 instead seem to use all available CPU threads (successfully) despite limiting threading with TF-statements. (By the way: This is an interesting point in itself. If OpenBlas cannot give them advantages what else do they do?)

A very surprising point was, however, that using a GPU did not make the factor much bigger – despite the fact that TF2 should be able to accelerate certain operations on a GPU by at least by a factor of 2 up to 5 as independent tests on matrix operations showed me. And a factor of > 2 between my GPU and the CPU is what I remember from TF1-times last year. So, either the CPU is better supported now or the GPU-support of TF2 has become worse compared to TF1. An interesting point, too, for further investigations …

An even bigger surprise was that I could reduce the factor for the given batch-size down to 2 by just two major, butsimple code changes! However, further testing also showed a huge dependency on the * batch size*chosen for training – which is another interesting point. Simple tests show that we may even be able to reduce the performance factor further by

- by using directly coupled matrix operations – if logically possible
- by using the basic low-level Python API for some operations

Hope, this sounds interesting for you.

# The reference model based on Keras

I used the following model as a reference

in a Jupyter environment executed on Firefox:

**Jupyter Cell 1**

# compact version # **************** import time import tensorflow as tf #from tensorflow import keras as K import keras as K from keras.datasets import mnist from keras import models from keras import layers from keras.utils import to_categorical from keras import regularizers from tensorflow.python.client import device_lib import os # use to work with CPU (CPU XLA ) only os.environ["CUDA_VISIBLE_DEVICES"] = "-1" # The following can only be done once - all CPU cores are used otherwise tf.config.threading.set_intra_op_parallelism_threads(4) tf.config.threading.set_inter_op_parallelism_threads(4) gpus = tf.config.experimental.list_physical_devices('GPU') if gpus: try: tf.config.experimental.set_virtual_device_configuration(gpus[0], [tf.config.experimental.VirtualDeviceConfiguration(memory_limit=1024)]) except RuntimeError as e: print(e) # if not yet done elsewhere #tf.compat.v1.disable_eager_execution() #tf.config.optimizer.set_jit(True) tf.debugging.set_log_device_placement(True) use_cpu_or_gpu = 0 # 0: cpu, 1: gpu # function for training def train(train_images, train_labels, epochs, batch_size, shuffle): network.fit(train_images, train_labels, epochs=epochs, batch_size=batch_size, shuffle=shuffle) # setup of the MLP network = models.Sequential() network.add(layers.Dense(70, activation='sigmoid', input_shape=(28*28,), kernel_regularizer=regularizers.l2(0.01))) #network.add(layers.Dense(80, activation='sigmoid')) #network.add(layers.Dense(50, activation='sigmoid')) network.add(layers.Dense(30, activation='sigmoid', kernel_regularizer=regularizers.l2(0.01))) network.add(layers.Dense(10, activation='sigmoid')) network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy']) # load MNIST mnist = K.datasets.mnist (X_train, y_train), (X_test, y_test) = mnist.load_data() # simple normalization train_images = X_train.reshape((60000, 28*28)) train_images = train_images.astype('float32') / 255 test_images = X_test.reshape((10000, 28*28)) test_images = test_images.astype('float32') / 255 train_labels = to_categorical(y_train) test_labels = to_categorical(y_test)

**Jupyter Cell 2**

# run it if use_cpu_or_gpu == 1: start_g = time.perf_counter() train(train_images, train_labels, epochs=35, batch_size=500, shuffle=True) end_g = time.perf_counter() test_loss, test_acc= network.evaluate(test_images, test_labels) print('Time_GPU: ', end_g - start_g) else: start_c = time.perf_counter() with tf.device("/CPU:0"): train(train_images, train_labels, epochs=35, batch_size=500, shuffle=True) end_c = time.perf_counter() test_loss, test_acc= network.evaluate(test_images, test_labels) print('Time_CPU: ', end_c - start_c) # test accuracy print('Acc:: ', test_acc)

**Typical output – first run: **

Epoch 1/35 60000/60000 [==============================] - 1s 16us/step - loss: 2.6700 - accuracy: 0.1939 Epoch 2/35 60000/60000 [==============================] - 0s 5us/step - loss: 2.2814 - accuracy: 0.3489 Epoch 3/35 60000/60000 [==============================] - 0s 5us/step - loss: 2.1386 - accuracy: 0.3848 Epoch 4/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.9996 - accuracy: 0.3957 Epoch 5/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.8941 - accuracy: 0.4115 Epoch 6/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.8143 - accuracy: 0.4257 Epoch 7/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.7556 - accuracy: 0.4392 Epoch 8/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.7086 - accuracy: 0.4542 Epoch 9/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.6726 - accuracy: 0.4664 Epoch 10/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.6412 - accuracy: 0.4767 Epoch 11/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.6156 - accuracy: 0.4869 Epoch 12/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.5933 - accuracy: 0.4968 Epoch 13/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.5732 - accuracy: 0.5078 Epoch 14/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.5556 - accuracy: 0.5180 Epoch 15/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.5400 - accuracy: 0.5269 Epoch 16/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.5244 - accuracy: 0.5373 Epoch 17/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.5106 - accuracy: 0.5494 Epoch 18/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.4969 - accuracy: 0.5613 Epoch 19/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.4834 - accuracy: 0.5809 Epoch 20/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.4648 - accuracy: 0.6112 Epoch 21/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.4369 - accuracy: 0.6520 Epoch 22/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.3976 - accuracy: 0.6821 Epoch 23/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.3602 - accuracy: 0.6984 Epoch 24/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.3275 - accuracy: 0.7084 Epoch 25/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.3011 - accuracy: 0.7147 Epoch 26/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.2777 - accuracy: 0.7199 Epoch 27/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.2581 - accuracy: 0.7261 Epoch 28/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.2411 - accuracy: 0.7265 Epoch 29/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.2259 - accuracy: 0.7306 Epoch 30/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.2140 - accuracy: 0.7329 Epoch 31/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.2003 - accuracy: 0.7355 Epoch 32/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.1890 - accuracy: 0.7378 Epoch 33/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.1783 - accuracy: 0.7410 Epoch 34/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.1700 - accuracy: 0.7425 Epoch 35/35 60000/60000 [==============================] - 0s 5us/step - loss: 1.1605 - accuracy: 0.7449 10000/10000 [==============================] - 0s 37us/step Time_CPU: 11.055424336002034 Acc:: 0.7436000108718872

A second run was a bit faster: 10.8 secs. Accuracy around: 0.7449.

The relatively low accuracy is mainly due to the regularization (and reasonable to avoid overfitting). Without regularization we would already have passed the 0.9 border.

My own **unoptimized** MLP-program was executed with the following parameter setting:

my_data_set="mnist_keras", n_hidden_layers = 2, ay_nodes_layers = [0, 70, 30, 0], n_nodes_layer_out = 10, num_test_records = 10000, # number of test data # Normalizing - you should play with scaler1 only for the time being scaler1 = 1, # 1: StandardScaler (full set), 1: Normalizer (per sample) scaler2 = 0, # 0: StandardScaler (full set), 1: MinMaxScaler (full set) b_normalize_X_before_preproc = False, b_normalize_X_after_preproc = True, my_loss_function = "LogLoss", n_size_mini_batch = 500, n_epochs = 35, lambda2_reg = 0.01, learn_rate = 0.001, decrease_const = 0.000001, init_weight_meth_L0 = "sqrt_nodes", # method to init weights in an interval defined by =>"sqrt_nodes" or a constant interval "const" init_weight_meth_Ln = "sqrt_nodes", # sqrt_nodes", "const" init_weight_intervals = [(-0.5, 0.5), (-0.5, 0.5), (-0.5, 0.5)], # in case of a constant interval init_weight_fact = 2.0, # extends the interval mom_rate = 0.00005, b_shuffle_batches = True, # shuffling the batches at the start of each epoch b_predictions_train = True, # test accuracy by predictions for ALL samples of the training set (MNIST: 60000) at the start of each epoch b_predictions_test = False, prediction_train_period = 1, # 1: each and every epoch is used for accuracy tests on the full training set prediction_test_period = 1, # 1: each and every epoch is used for accuracy tests on the full test dataset

People familiar with my other article series on the MLP program know the parameters. But I think their names and comments are clear enough.

With a measurement of accuracy based on a forward propagation of the complete training set after each and every epoch (with the adjusted weights) I got a run time of 60 secs.

With accuracy measurements based on error tracking for batches and averaging over all batches, I get 49.5 secs (on 4 CPU threads). So, this is the mentioned factor between 5 and 6.

(By the way: The test indicates some space for improvement on the “Forward Propagation” 🙂 We shall take care of this in the next article of this series – promised).

So, these were the references or baselines for improvements.

# Two measures – and a significant acceleration

Well, let us look at the results after two major code changes. With a test of accuracy performed on the full training set of 60000 samples at the start of each epoch I get the following result :

------------------ Starting epoch 35 Time_CPU for epoch 35 0.5518779030026053 relative CPU time portions: shuffle: 0.05 batch loop: 0.58 prediction: 0.37 Total CPU-time: 19.065050211000198 learning rate = 0.0009994051838157095 total costs of training set = 5843.522 rel. reg. contrib. to total costs = 0.0013737131 total costs of last mini_batch = 56.300297 rel. reg. contrib. to batch costs = 0.14256112 mean abs weight at L0 : 0.06393985 mean abs weight at L1 : 0.37341583 mean abs weight at L2 : 1.302389 avg total error of last mini_batch = 0.00709 presently reached train accuracy = 0.99072 ------------------- Total training Time_CPU: 19.04528829299714

With accuracy taken only from the error of a batch:

avg total error of last mini_batch = 0.00806 presently reached train accuracy = 0.99194 ------------------- Total training Time_CPU: 11.331006342999899

Isn’t this good news? A time of **11.3** secs is pretty close to what Keras provides us with! (Well, at least for a batch size of 500). And with a better result regarding accuracy on my side – but this has to do with a probably different

handling of learning rates and the precise translation of the L2-regularization parameter for batches.

How did I get to this point? As said: Two measures were sufficient.

# A big leap in performance by turning to float32 precision

So far I have never cared too much for defining the level of precision by which Numpy handles arrays with floating point numbers. In the context of Machine Learning this is a profound mistake. on a 64bit CPU many time consuming operations can gain almost a factor of 2 in performance when using float 32 precision – if the programmers tweaked everything. And I assume the Numpy guys did it.

So: Just use “dtype=np.float32” (np means “numpy” which I always import as “np”) whenever you initialize numpy arrays!

For the readers following my other series: You should look at multiple methods performing some kind of initialization of my “MyANN”-class. Here is a list:

def _handle_input_data(self): ..... self._y = np.array([int(i) for i in self._y], dtype=np.float32) ..... self._X = self._X.astype(np.float32) self._y = self._y.astype(np.int32) ..... def _encode_all_y_labels(self, b_print=True): ..... self._ay_onehot = np.zeros((self._n_labels, self._y_train.shape[0]), dtype=np.float32) self._ay_oneval = np.zeros((self._n_labels, self._y_train.shape[0], 2), dtype=np.float32) ..... def _create_WM_Input(self): ..... w0 = w0.astype(dtype=np.float32) ..... def _create_WM_Hidden(self): ..... w_i_next = w_i_next.astype(dtype=np.float32) ..... def _create_momentum_matrices(self): ..... self._li_mom[i] = np.zeros(self._li_w[i].shape, dtype=np.float32) ..... def _prepare_epochs_and_batches(self, b_print = True): ..... self._ay_theta = -1 * np.ones(self._shape_epochs_batches, dtype=np.float32) self._ay_costs = -1 * np.ones(self._shape_epochs_batches, dtype=np.float32) self._ay_reg_cost_contrib = -1 * np.ones(self._shape_epochs_batches, dtype=np.float32) ..... self._ay_period_test_epoch = -1 * np.ones(shape_test_epochs, dtype=np.float32) self._ay_acc_test_epoch = -1 * np.ones(shape_test_epochs, dtype=np.float32) self._ay_err_test_epoch = -1 * np.ones(shape_test_epochs, dtype=np.float32) self._ay_period_train_epoch = -1 * np.ones(shape_train_epochs, dtype=np.float32) self._ay_acc_train_epoch = -1 * np.ones(shape_train_epochs, dtype=np.float32) self._ay_err_train_epoch = -1 * np.ones(shape_train_epochs, dtype=np.float32) self._ay_tot_costs_train_epoch = -1 * np.ones(shape_train_epochs, dtype=np.float32) self._ay_rel_reg_train_epoch = -1 * np.ones(shape_train_epochs, dtype=np.float32) ..... self._ay_mean_abs_weight = -10 * np.ones(shape_weights, dtype=np.float32) ..... def _add_bias_neuron_to_layer(self, A, how='column'): ..... A_new = np.ones((A.shape[0], A.shape[1]+1), dtype=np.float32) ..... A_new = np.ones((A.shape[0]+1, A.shape[1]), dtype=np.float32) .....

After I applied these changes the factor in comparison to Keras went down to **3.1** – for a batch size of 500. Good news after a first simple step!

# Reducing the CPU time once more

The next step required a bit more thinking. When I went through further more detailed tests of CPU consumption for various steps during training I found that the error back propagation through the network required *significantly* more time than the forward propagation.

At first sight this seems to be logical. There are more operations to be done between layers – real matrix multiplications with np.dot() (or np.matmul()) and element-wise multiplications with the “*”-operation. See also my PDF on the basic math:

Back_Propagation_1.0_200216.

But this is wrong assumption: When I measured CPU times in detail I saw that such operations took most time when network layer L0 – i.e. the input layer of the MLP – got involved. This also seemed to be reasonable: the weight matrix is biggest there; the input layer of all layers has most neuron nodes.

But when I went through the code I saw that I just had been too lazy whilst coding back propagation:

''' -- Method to handle error BW propagation for a mini-batch --''' def _bw_propagation(self, ay_y_enc, li_Z_in, li_A_out, li_delta_out, li_delta, li_D, li_grad, b_print = True, b_internal_timing = False): # Note: the lists li_Z_in, li_A_out were already filled by _fw_propagation() for the present batch # Initiate BW propagation - provide delta-matrices for outermost layer # *********************** # Input Z at outermost layer E (4 layers -> layer 3) ay_Z_E = li_Z_in[self._n_total_layers-1] # Output A at outermost layer E (was calculated by output function) ay_A_E = li_A_out[self._n_total_layers-1] # Calculate D-matrix (derivative of output function) at outmost the layer - presently only D_sigmoid ay_D_E = self._calculate_D_E(ay_Z_E=ay_Z_E, b_print=b_print ) # Get the 2 delta matrices for the outermost layer (only layer E has 2 delta-matrices) ay_delta_E, ay_delta_out_E = self._calculate_delta_E(ay_y_enc=ay_y_enc, ay_A_E=ay_A_E, ay_D_E=ay_D_E, b_print=b_print) # add the matrices at the outermost layer to their lists ; li_delta_out gets only one element idxE = self._n_total_layers - 1 li_delta_out[idxE] = ay_delta_out_E # this happens only once li_delta[idxE] = ay_delta_E li_D[idxE] = ay_D_E li_grad[idxE] = None # On the outermost layer there is no gradient ! # Loop over all layers in reverse direction # ****************************************** # index range of target layers N in BW direction (starting with E-1 => 4 layers -> layer 2)) range_N_bw_layer = reversed(range(0, self._n_total_layers-1)) # must be -1 as the last element is not taken # loop over layers for N in range_N_bw_layer: # Back Propagation operations between layers N+1 and N # ******************************************************* # this method handles the special treatment of bias nodes in Z_in, too ay_delta_N, ay_D_N, ay_grad_ N = self._bw_prop_Np1_to_N( N=N, li_Z_in=li_Z_in, li_A_out=li_A_out, li_delta=li_delta, b_print=False ) # add matrices to their lists li_delta[N] = ay_delta_N li_D[N] = ay_D_N li_grad[N]= ay_grad_N return

with the following key function:

''' -- Method to calculate the BW-propagated delta-matrix and the gradient matrix to/for layer N ''' def _bw_prop_Np1_to_N(self, N, li_Z_in, li_A_out, li_delta): ''' BW-error-propagation between layer N+1 and N Inputs: li_Z_in: List of input Z-matrices on all layers - values were calculated during FW-propagation li_A_out: List of output A-matrices - values were calculated during FW-propagation li_delta: List of delta-matrices - values for outermost ölayer E to layer N+1 should exist Returns: ay_delta_N - delta-matrix of layer N (required in subsequent steps) ay_D_N - derivative matrix for the activation function on layer N ay_grad_N - matrix with gradient elements of the cost fnction with respect to the weights on layer N ''' # Prepare required quantities - and add bias neuron to ay_Z_in # **************************** # Weight matrix meddling between layers N and N+1 ay_W_N = self._li_w[N] # delta-matrix of layer N+1 ay_delta_Np1 = li_delta[N+1] # !!! Add row (for bias) to Z_N intermediately !!! ay_Z_N = li_Z_in[N] ay_Z_N = self._add_bias_neuron_to_layer(ay_Z_N, 'row') # Derivative matrix for the activation function (with extra bias node row) ay_D_N = self._calculate_D_N(ay_Z_N) # fetch output value saved during FW propagation ay_A_N = li_A_out[N] # Propagate delta # ************** # intermediate delta ay_delta_w_N = ay_W_N.T.dot(ay_delta_Np1) # final delta ay_delta_N = ay_delta_w_N * ay_D_N # reduce dimension again (bias row) ay_delta_N = ay_delta_N[1:, :] # Calculate gradient # ******************** # required for all layers down to 0 ay_grad_N = np.dot(ay_delta_Np1, ay_A_N.T) # regularize gradient (!!!! without adding bias nodes in the L1, L2 sums) ay_grad_N[:, 1:] += (self._li_w[N][:, 1:] * self._lambda2_reg + np.sign(self._li_w[N][:, 1:]) * self._lambda1_reg) return ay_delta_N, ay_D_N, ay_grad_N

Now, look at the eventual code:

''' -- Method to calculate the BW-propagated delta-matrix and the gradient matrix to/for layer N ''' def _bw_prop_Np1_to_N(self, N, li_Z_in, li_A_out, li_delta, b_print=False): ''' BW-error-propagation between layer N+1 and N .... ''' # Prepare required quantities - and add bias neuron to ay_Z_in # **************************** # Weight matrix meddling between layers N and N+1 ay_W_N = self._li_w[N] ay_delta_Np1 = li_delta[N+1] # fetch output value saved during FW propagation ay_A_N = li_A_out[N] # Optimization ! if N > 0: ay_Z_N = li_Z_in[N] # !!! Add intermediate row (for bias) to Z_N !!! ay_Z_N = self._add_bias_neuron_to_layer(ay_Z_N, 'row') # Derivative matrix for the activation function (with extra bias node row) ay_D_N = self._calculate_D_N(ay_Z_N) # Propagate delta # ************** # intermediate delta ay_delta_w_N = ay_W_N.T.dot(ay_delta_Np1) # final delta ay_delta_N = ay_delta_w_N * ay_D_N # reduce dimension again ay_delta_N = ay_delta_N[1:, :] else: ay_delta_N = None ay_D_N = None # Calculate gradient # ******************** # required for all layers down to 0 ay_grad_N = np.dot(ay_delta_Np1, ay_A_N.T) # regularize gradient (!!!! without adding bias nodes in the L1, L2 sums) if self._lambda2_reg > 0.0: ay_grad_N[:, 1:] += self._li_w[N][:, 1:] * self._lambda2_reg if self._lambda1_reg > 0.0: ay_grad_N[:, 1:] += np.sign(self._li_w[N][:, 1:]) * self._lambda1_reg return ay_delta_N, ay_D_N, ay_grad_N

You have, of course, detected the most important change:

We do not need to propagate any delta-matrices (originally coming from the error deviation at the output layer) down to layer 1!

This is due to the somewhat staggered nature of error back propagation – see the PDF on the math again. Between the first hidden layer L1 and the input layer L0 we only need to fetch the output matrix A at L0 to be able to calculate the gradient components for the weights in the weight matrix connecting L0 and L1. This saves us from the biggest matrix multiplication – and thus reduces computational time significantly.

Another bit of CPU time can be saved by calculating only the regularization terms really asked for; for my simple densely populated network I almost never use Lasso regularization; so L1 = 0.

These changes got me down to the values mentioned above. And, note: The CPU time for backward propagation then drops to the level of forward propagation. So: Be somewhat skeptical about your coding if backward propagation takes much more CPU time than forward propagation!

# Dependency on the batch size

I should remark that TF2 still brings some major and remarkable advantages with it. Its strength becomes clear when we go to much bigger batch sizes than 500:

When we e.g. take a size of 10000 samples in a batch, the required time of Keras and TF2 goes down to 6.4 secs. This is again a factor of roughly 1.75 faster.

I do not see any such acceleration with batch size in case of my own program!

More detailed tests showed that I do not gain speed with a batch size over 1000; the CPU time increases linearly from that point on. This actually seems to be a limitation of Numpy and OpenBlas on my system.

Because , I have some reasons to believe that TF2 also uses some basic OpenBlas routines, this is an indication that we need to put more brain into further optimization.

# Conclusion

We saw in this article that ML programs based on Python and Numpy may gain a boost by using only dtype=float32 and the related accuracy for Numpy arrays. In addition we saw that avoiding unnecessary propagation steps between the first hidden and at the input layer helps a lot.

In the next article of this series we shall look a bit at the performance of forward propagation – especially during accuracy tests on the training and test data set.

# Further articles in this series

MLP, Numpy, TF2 – performance issues – Step II – bias neurons,

F- or C- contiguous arrays and performance

MLP, Numpy, TF2 – performance issues – Step III – a correction to BW propagation