Good Articles to learn how to implement a neural network 2
Vectorization
This part will cover:
The previous tutorial described a very simple neural network with only one input, one hidden neuron and one output. This tutorial will describe a neural network that takes 2-dimensional input samples, projects them onto a 3-dimensional hidden layer, and classifies them with a 2-dimensional softmax output classfier, this softmax function is explained in intermezzo 2 . While we didn’t add the bias parameters to the previous 2 models, we will add them to this model. We will work out the vector computations needed to optimize and run this neural network in this tutorial. The network of this model is shown in the following figure:
The notebook starts out with importing the libraries we need:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
# Python imports import numpy as np # Matrix and vector computation package import sklearn.datasets # To generate the dataset import matplotlib.pyplot as plt # Plotting library from matplotlib.colors import colorConverter, ListedColormap # Some plotting functions from mpl_toolkits.mplot3d import Axes3D # 3D plots from matplotlib import cm # Colormaps # Allow matplotlib to plot inside this notebook %matplotlib inline # Set the seed of the numpy random number generator so that the tutorial is reproducable np.random.seed(seed=1) |
Define the dataset
In this example the target classes t
corresponding to the inputs x will be generated from 2 class distributions: red (t=0) and blue (t=1
). Where the red class is a circular distribution that surrounds the distribution of the blue class. The dataset is generated by the scikit-learn make_circles method.
This results in a 2D dataset that is not linearly separable. The model from part 2 won’t be able to classify both classes correctly since it can learn only linear separators. By adding a hidden layer, the model will be able to train a non-linear separator.
The N
input samples with 2 variables each are given as a N×2 matrix X
:
Where xij
is the value of the j-th variable of the i
-th input sample.
Since the softmax output function is used the targets for each input sample are given as a N×2
matrix T
:
Where tij=1
if and only if the i-th input sample belongs to class j
. So blue points are labelled T = [0 1] and red points are labelled T = [1 0].
|
1 2 3 4 5 6 7 8 9 10 11 |
# Generate the dataset X, t = sklearn.datasets.make_circles(n_samples=100, shuffle=False, factor=0.3, noise=0.1) T = np.zeros((100,2)) # Define target matrix T[t==1,1] = 1 T[t==0,0] = 1 # Separate the red and blue points for plotting x_red = X[t==0] x_blue = X[t==1] print('shape of X: {}'.format(X.shape)) print('shape of T: {}'.format(T.shape)) |
|
1 2 3 |
shape of X: (100, 2) shape of T: (100, 2) |
|
1 2 3 4 5 6 7 8 9 10 |
# Plot both classes on the x1, x2 plane plt.plot(x_red[:,0], x_red[:,1], 'ro', label='class red') plt.plot(x_blue[:,0], x_blue[:,1], 'bo', label='class blue') plt.grid() plt.legend(loc=1) plt.xlabel('$x_1$', fontsize=15) plt.ylabel('$x_2$', fontsize=15) plt.axis([-1.5, 1.5, -1.5, 1.5]) plt.title('red vs blue classes in the input space') plt.show() |
|
1 2 3 4 5 6 7 8 9 10 |
# Plot both classes on the x1, x2 plane plt.plot(x_red[:,0], x_red[:,1], 'ro', label='class red') plt.plot(x_blue[:,0], x_blue[:,1], 'bo', label='class blue') plt.grid() plt.legend(loc=1) plt.xlabel('$x_1$', fontsize=15) plt.ylabel('$x_2$', fontsize=15) plt.axis([-1.5, 1.5, -1.5, 1.5]) plt.title('red vs blue classes in the input space') plt.show() |
Vectorization of backpropagation
1. Vectorization of the forward step
Compute activations of hidden layer
The 2-dimensional inputs X
are projected onto the 3 dimensions of the hidden layer H by the weight matrix Wh (whij is the weight of the connection between input variable i and hidden neuron activation j) and bias vector bh
:
following computation:
With σ
the logistic function, and with H resulting in a N×3
matrix.
This computation flow is illustrated by the figure below. Each input xij
is multiplied with the weight parameters from the corresponding column whj1,whj2,whj3. The multiplication results for each row k (xij∗whjk) are summed to form the zik, after which these are transformed by the logistic function σ to the hik. Note that the bias Bh can be incorporated in Wh by adding a new variable to each input xi that is always +1
.
bh
and Wh
are represented in the code below by bh and Wh respectively. The hidden layer activations are computed by the hidden_activations(X, Wh, bh) method.
Compute activations of output
To compute the output activations the hidden layer activations can be projected onto the 2-dimensional output layer. This is done by the 3×2
weight matrix Wo (woij is the weight of the connection between hidden layer neuron i and output activation j) and 2×1 bias vector bo
:
following computation:
With ς
the softmax function, Y resulting in a n×2 matrix, zod the d-th column of the Zo matrix, wod the d-th column of the Wo matrix, and bod the d-th element of the bo
vector.
bo
and Wo
are represented in the code below by bo and Wo respectively. The hidden layer activations are computed by the output_activations(H, Wo, bo) method.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# Define the logistic function def logistic(z): return 1 / (1 + np.exp(-z)) # Define the softmax function def softmax(z): return np.exp(z) / np.sum(np.exp(z), axis=1, keepdims=True) # Function to compute the hidden activations def hidden_activations(X, Wh, bh): return logistic(X.dot(Wh) + bh) # Define output layer feedforward def output_activations(H, Wo, bo): return softmax(H.dot(Wo) + bo) # Define the neural network function def nn(X, Wh, bh, Wo, bo): return output_activations(hidden_activations(X, Wh, bh), Wo, bo) # Define the neural network prediction function that only returns # 1 or 0 depending on the predicted class def nn_predict(X, Wh, bh, Wo, bo): return np.around(nn(X, Wh, bh, Wo, bo)) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# Define the logistic function def logistic(z): return 1 / (1 + np.exp(-z)) # Define the softmax function def softmax(z): return np.exp(z) / np.sum(np.exp(z), axis=1, keepdims=True) # Function to compute the hidden activations def hidden_activations(X, Wh, bh): return logistic(X.dot(Wh) + bh) # Define output layer feedforward def output_activations(H, Wo, bo): return softmax(H.dot(Wo) + bo) # Define the neural network function def nn(X, Wh, bh, Wo, bo): return output_activations(hidden_activations(X, Wh, bh), Wo, bo) # Define the neural network prediction function that only returns # 1 or 0 depending on the predicted class def nn_predict(X, Wh, bh, Wo, bo): return np.around(nn(X, Wh, bh, Wo, bo)) |
2. Vectorization of the backward step
Vectorization of the output layer backward step
Compute the error at the output
The output layer is a softmax layer with corresponding cross-entropy cost function, which are described in detail in intermezzo 2 . The cost function ξ
for N samples and C
classes used in this model is defined as:
The error gradient δo
of this cost function at the softmax output layer is simply:
With Zo
a n×2 matrix of inputs to the softmax layer (Zo=H⋅Wo+bo), and T a n×2 target matrix that corresponds to Y. Note that δo also results in a n×2
matrix.
δo
will be defined in the code as Eo and will be computed by the error_output(Y, T) method defined below.
Update the output layer weights
At the output the gradient δwoj
over all N samples is computed by ∂ξ/∂woj
:
With woj
the j-th row of Wo and thus a 1×2
vector. This formula can be written out as matrix operations with the parameters of the output layer:
The resulting gradient is a 3×2
JWo
will be defined in the code as JWo and will be computed by the gradient_weight_out(H, Eo) method defined below.
Update the output layer bias
The bias bo
can be updated in the same manner. The formula to compute the gradient ∂ξ/∂bo for batch processing over all N
samples is:
The resulting gradient is a 2×1
Jacobian matrix:
Jbo
will be defined in the code as Jbo and will be computed by the gradient_bias_out(Eo) method defined below.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# Define the cost function def cost(Y, T): return - np.multiply(T, np.log(Y)).sum() # Define the error function at the output def error_output(Y, T): return Y - T # Define the gradient function for the weight parameters at the output layer def gradient_weight_out(H, Eo): return H.T.dot(Eo) # Define the gradient function for the bias parameters at the output layer def gradient_bias_out(Eo): return np.sum(Eo, axis=0, keepdims=True) |
Vectorization of the hidden layer backward step
Compute the error at the hidden layer
The error gradient δh
of the cost function at the hidden layer is defined as:
With Zh
a n×3 matrix of inputs to the logistic functions in the hidden neurons (Zh=X⋅Wh+bh). Note that δh will also result in a N×3
matrix.
Lets first derive the error gradient δhij
for one sample i at hidden neuron j. The gradients that backpropagate from the previous layer via the weighted connections are summed for each origin hij
.
Where woj
is the j-th row of Wo, and thus a 1×2 vector and δoi is a 1×2 vector. The full N×3 error matrix δh
can thus be calculated as:
With ∘
the elementwise product .
δh
will be defined in the code as Eh and will be computed by the error_hidden(H, Wo, Eo) method defined below.
Update the hidden layer weights
At the hidden layer the gradient ∂ξ/∂whj
over all N
samples can be computed by:
With whj
the j-th row of Wh and thus a 1×3
vector. We can write this formula out as matrix operations with the parameters of the hidden layer:
The resulting gradient is a 2×3
JWh
will be defined in the code as JWh and will be computed by the gradient_weight_hidden(X, Eh) method defined below.
Update the hidden layer bias
The bias bh
can be updated in the same manner. The formula to compute the gradient ∂ξ/∂bh for batch processing over all N
samples is:
The resulting gradient is a 1×3
Jacobian matrix:
Jbh
will be defined in the code as Jbh and will be computed by the gradient_bias_hidden(Eh) method defined below.
|
1 2 3 4 5 6 7 8 9 10 11 12 |
# Define the error function at the hidden layer def error_hidden(H, Wo, Eo): # H * (1-H) * (E . Wo^T) return np.multiply(np.multiply(H,(1 - H)), Eo.dot(Wo.T)) # Define the gradient function for the weight parameters at the hidden layer def gradient_weight_hidden(X, Eh): return X.T.dot(Eh) # Define the gradient function for the bias parameters at the output layer def gradient_bias_hidden(Eh): return np.sum(Eh, axis=0, keepdims=True) |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
# Define the error function at the hidden layer def error_hidden(H, Wo, Eo): # H * (1-H) * (E . Wo^T) return np.multiply(np.multiply(H,(1 - H)), Eo.dot(Wo.T)) # Define the gradient function for the weight parameters at the hidden layer def gradient_weight_hidden(X, Eh): return X.T.dot(Eh) # Define the gradient function for the bias parameters at the output layer def gradient_bias_hidden(Eh): return np.sum(Eh, axis=0, keepdims=True) |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
# Define the error function at the hidden layer def error_hidden(H, Wo, Eo): # H * (1-H) * (E . Wo^T) return np.multiply(np.multiply(H,(1 - H)), Eo.dot(Wo.T)) # Define the gradient function for the weight parameters at the hidden layer def gradient_weight_hidden(X, Eh): return X.T.dot(Eh) # Define the gradient function for the bias parameters at the output layer def gradient_bias_hidden(Eh): return np.sum(Eh, axis=0, keepdims=True) |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
# Define the error function at the hidden layer def error_hidden(H, Wo, Eo): # H * (1-H) * (E . Wo^T) return np.multiply(np.multiply(H,(1 - H)), Eo.dot(Wo.T)) # Define the gradient function for the weight parameters at the hidden layer def gradient_weight_hidden(X, Eh): return X.T.dot(Eh) # Define the gradient function for the bias parameters at the output layer def gradient_bias_hidden(Eh): return np.sum(Eh, axis=0, keepdims=True) |
Gradient checking
Programming the computation of the backpropagation gradient is prone to bugs. This is why it is recommended to check the gradients of your models. Gradient checking is done by computing the numerical gradient of each parameter, and compare this value to the gradient found by backpropagation.
The numerical gradient ∂ξ/∂θi
for a parameter θi
can be computed by:
Where f
if the neural network function that takes the input X and all parameters θ, and ϵ is the small change that is used to peturbate the parameter θi
.
The numerical gradient for each parameter should be close to the backpropagation gradient for that parameter.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Initialize weights and biases init_var = 1 # Initialize hidden layer parameters bh = np.random.randn(1, 3) * init_var Wh = np.random.randn(2, 3) * init_var # Initialize output layer parameters bo = np.random.randn(1, 2) * init_var Wo = np.random.randn(3, 2) * init_var # Compute the gradients by backpropagation # Compute the activations of the layers H = hidden_activations(X, Wh, bh) Y = output_activations(H, Wo, bo) # Compute the gradients of the output layer Eo = error_output(Y, T) JWo = gradient_weight_out(H, Eo) Jbo = gradient_bias_out(Eo) # Compute the gradients of the hidden layer Eh = error_hidden(H, Wo, Eo) JWh = gradient_weight_hidden(X, Eh) Jbh = gradient_bias_hidden(Eh) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Initialize weights and biases init_var = 1 # Initialize hidden layer parameters bh = np.random.randn(1, 3) * init_var Wh = np.random.randn(2, 3) * init_var # Initialize output layer parameters bo = np.random.randn(1, 2) * init_var Wo = np.random.randn(3, 2) * init_var # Compute the gradients by backpropagation # Compute the activations of the layers H = hidden_activations(X, Wh, bh) Y = output_activations(H, Wo, bo) # Compute the gradients of the output layer Eo = error_output(Y, T) JWo = gradient_weight_out(H, Eo) Jbo = gradient_bias_out(Eo) # Compute the gradients of the hidden layer Eh = error_hidden(H, Wo, Eo) JWh = gradient_weight_hidden(X, Eh) Jbh = gradient_bias_hidden(Eh) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Initialize weights and biases init_var = 1 # Initialize hidden layer parameters bh = np.random.randn(1, 3) * init_var Wh = np.random.randn(2, 3) * init_var # Initialize output layer parameters bo = np.random.randn(1, 2) * init_var Wo = np.random.randn(3, 2) * init_var # Compute the gradients by backpropagation # Compute the activations of the layers H = hidden_activations(X, Wh, bh) Y = output_activations(H, Wo, bo) # Compute the gradients of the output layer Eo = error_output(Y, T) JWo = gradient_weight_out(H, Eo) Jbo = gradient_bias_out(Eo) # Compute the gradients of the hidden layer Eh = error_hidden(H, Wo, Eo) JWh = gradient_weight_hidden(X, Eh) Jbh = gradient_bias_hidden(Eh) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Initialize weights and biases init_var = 1 # Initialize hidden layer parameters bh = np.random.randn(1, 3) * init_var Wh = np.random.randn(2, 3) * init_var # Initialize output layer parameters bo = np.random.randn(1, 2) * init_var Wo = np.random.randn(3, 2) * init_var # Compute the gradients by backpropagation # Compute the activations of the layers H = hidden_activations(X, Wh, bh) Y = output_activations(H, Wo, bo) # Compute the gradients of the output layer Eo = error_output(Y, T) JWo = gradient_weight_out(H, Eo) Jbo = gradient_bias_out(Eo) # Compute the gradients of the hidden layer Eh = error_hidden(H, Wo, Eo) JWh = gradient_weight_hidden(X, Eh) Jbh = gradient_bias_hidden(Eh) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Initialize weights and biases init_var = 1 # Initialize hidden layer parameters bh = np.random.randn(1, 3) * init_var Wh = np.random.randn(2, 3) * init_var # Initialize output layer parameters bo = np.random.randn(1, 2) * init_var Wo = np.random.randn(3, 2) * init_var # Compute the gradients by backpropagation # Compute the activations of the layers H = hidden_activations(X, Wh, bh) Y = output_activations(H, Wo, bo) # Compute the gradients of the output layer Eo = error_output(Y, T) JWo = gradient_weight_out(H, Eo) Jbo = gradient_bias_out(Eo) # Compute the gradients of the hidden layer Eh = error_hidden(H, Wo, Eo) JWh = gradient_weight_hidden(X, Eh) Jbh = gradient_bias_hidden(Eh) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# Combine all parameter matrices in a list params = [Wh, bh, Wo, bo] # Combine all parameter gradients in a list grad_params = [JWh, Jbh, JWo, Jbo] # Set the small change to compute the numerical gradient eps = 0.0001 # Check each parameter matrix for p_idx in range(len(params)): # Check each parameter in each parameter matrix for row in range(params[p_idx].shape[0]): for col in range(params[p_idx].shape[1]): # Copy the parameter matrix and change the current parameter slightly p_matrix_min = params[p_idx].copy() p_matrix_min[row,col] -= eps p_matrix_plus = params[p_idx].copy() p_matrix_plus[row,col] += eps # Copy the parameter list, and change the updated parameter matrix params_min = params[:] params_min[p_idx] = p_matrix_min params_plus = params[:] params_plus[p_idx] = p_matrix_plus # Compute the numerical gradient grad_num = (cost(nn(X, *params_plus), T)-cost(nn(X, *params_min), T))/(2*eps) # Raise error if the numerical grade is not close to the backprop gradient if not np.isclose(grad_num, grad_params[p_idx][row,col]): raise ValueError('Numerical gradient of {:.6f} is not close to the backpropagation gradient of {:.6f}!'.format(float(grad_num), float(grad_params[p_idx][row,col]))) print('No gradient errors found') |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
# Combine all parameter matrices in a list params = [Wh, bh, Wo, bo] # Combine all parameter gradients in a list grad_params = [JWh, Jbh, JWo, Jbo] # Set the small change to compute the numerical gradient eps = 0.0001 # Check each parameter matrix for p_idx in range(len(params)): # Check each parameter in each parameter matrix for row in range(params[p_idx].shape[0]): for col in range(params[p_idx].shape[1]): # Copy the parameter matrix and change the current parameter slightly p_matrix_min = params[p_idx].copy() p_matrix_min[row,col] -= eps p_matrix_plus = params[p_idx].copy() p_matrix_plus[row,col] += eps # Copy the parameter list, and change the updated parameter matrix params_min = params[:] params_min[p_idx] = p_matrix_min params_plus = params[:] params_plus[p_idx] = p_matrix_plus # Compute the numerical gradient grad_num = (cost(nn(X, *params_plus), T)-cost(nn(X, *params_min), T))/(2*eps) # Raise error if the numerical grade is not close to the backprop gradient if not np.isclose(grad_num, grad_params[p_idx][row,col]): raise ValueError('Numerical gradient of {:.6f} is not close to the backpropagation gradient of {:.6f}!'.format(float(grad_num), float(grad_params[p_idx][row,col]))) print('No gradient errors found') |
|
1 2 |
No gradient errors found |
Backpropagation updates with momentum
In the previous examples we used simple gradient decent to optimize the parameters with respect to the cost function (which was convex in the first and second example). Multilayer neural networks with non-linear activation functions and many parameters are highly unlikely to have convex cost functions. Simple gradient decent is not the best method to find a global minimum of a non-convex cost function since it is a local optimization method that tends to converge to a local minimum .
To solve this issue this example uses a variant of gradient decent called momentum . The method of momentum can be visualized as a ball rolling down the surface of the cost function, this ball will increase in velocity while rolling downhill, and will decrease in velocity when rolling uphill. This momentum update is formulated as:
Where V(i)
is the velocity of the parameters at iteration i, θ(i) is the location of the parameters at iteration i, ∂ξ/∂θ(i) the gradient of the parameters with respect to the cost function at iteration i, λ how much the velocity decreases due to ‘resistance’ and μ
the learning rate (how much the gradient affects the velocity). This formula can be visualised as in the following illustration:
The velocities VWh,Vbh,VWo,Vbo,
corresponding to the parameters Wh,bh,Wo,bo
are represented in the code by VWh , Vbh , VWo , Vbo . And kept in a list Vs . They are updated by the update_velocity(X, T, ls_of_params, Vs, momentum_term, learning_rate) method. After updating the velocities the parameters are updated via the update_params(ls_of_params, Vs) method.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# Define the update function to update the network parameters over 1 iteration def backprop_gradients(X, T, Wh, bh, Wo, bo): # Compute the output of the network # Compute the activations of the layers H = hidden_activations(X, Wh, bh) Y = output_activations(H, Wo, bo) # Compute the gradients of the output layer Eo = error_output(Y, T) JWo = gradient_weight_out(H, Eo) Jbo = gradient_bias_out(Eo) # Compute the gradients of the hidden layer Eh = error_hidden(H, Wo, Eo) JWh = gradient_weight_hidden(X, Eh) Jbh = gradient_bias_hidden(Eh) return [JWh, Jbh, JWo, Jbo] def update_velocity(X, T, ls_of_params, Vs, momentum_term, learning_rate): # ls_of_params = [Wh, bh, Wo, bo] # Js = [JWh, Jbh, JWo, Jbo] Js = backprop_gradients(X, T, *ls_of_params) return [momentum_term * V - learning_rate * J for V,J in zip(Vs, Js)] def update_params(ls_of_params, Vs): # ls_of_params = [Wh, bh, Wo, bo] # Vs = [VWh, Vbh, VWo, Vbo] return [P + V for P,V in zip(ls_of_params, Vs)] |
Training the neural network model with this momentum method over 300
iterations on the dataset X and T
defined in the beginning results in the training convergence plotted in the figure below. This smooth convergence would almost never result from simple gradient descent. You can try it out yourself and implement gradient descent optimization for this model. You will notice that if you run your gradient descent a couple of times from different starting points that it will almost always converge to a sub-optimal solution. Note that momentum is less sensitive to the learning rate than gradient descent, but the learning rate hyperparameter still needs to be tuned separately. In this tutorial, this learning rate was tuned by experimentation.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# Run backpropagation # Initialize weights and biases init_var = 0.1 # Initialize hidden layer parameters bh = np.random.randn(1, 3) * init_var Wh = np.random.randn(2, 3) * init_var # Initialize output layer parameters bo = np.random.randn(1, 2) * init_var Wo = np.random.randn(3, 2) * init_var # Parameters are already initilized randomly with the gradient checking # Set the learning rate learning_rate = 0.02 momentum_term = 0.9 # define the velocities Vs = [VWh, Vbh, VWo, Vbo] Vs = [np.zeros_like(M) for M in [Wh, bh, Wo, bo]] # Start the gradient descent updates and plot the iterations nb_of_iterations = 300 # number of gradient descent updates lr_update = learning_rate / nb_of_iterations # learning rate update rule ls_costs = [cost(nn(X, Wh, bh, Wo, bo), T)] # list of cost over the iterations for i in range(nb_of_iterations): # Update the velocities and the parameters Vs = update_velocity(X, T, [Wh, bh, Wo, bo], Vs, momentum_term, learning_rate) Wh, bh, Wo, bo = update_params([Wh, bh, Wo, bo], Vs) ls_costs.append(cost(nn(X, Wh, bh, Wo, bo), T)) |
|
1 2 3 4 5 6 7 |
# Plot the cost over the iterations plt.plot(ls_costs, 'b-') plt.xlabel('iteration') plt.ylabel('$\\xi$', fontsize=15) plt.title('Decrease of cost over backprop iteration') plt.grid() plt.show() |