Real case¶
Note
In this section, indices enable to number the nodes in a layer (example: \(a_j\) is the \(j\)-th node of the last layer which has \(n_j\) nodes).
A forward neural network transform an input \(x\) composed by \(n_i\) components \(x_i\) into an output layer \(j\) composed by \(n_j\) components \(a_j\). Between those, several hidden layers are found. An example in the training data is composed by a pair \((x, y)\): an input and a target. A target is composed by \(n_j\) components \(y_j\) (same number as the output layer).
Every layer can have a different number of nodes. The figure Real case FNN (basecase) shows this graph. Many edges, weights and biases are omitted for clarity. Every node of each layer is actually connected to every node of the next. The dashed lines on the right symbolize a comparison between the output layer \(j\) and the training target \(y\). The dotted lines indicate the index of nodes inside a layer.
Focusing on the last layer \(j\), the only weight and bias which appear are the weight \(w_{jk}\) between nodes \(a_k\) and \(a_j\) and the bias \(b_j\) of node \(a_j\). The goal is to be able to compute the gradients of the loss function with respect to \(w_{jk}\) and \(b_j\). These gradients will be used in the gradient descent algorithm to optimize these weights and biases in order to minimize the loss function over all the training examples. Let us use \(t\) as the index for the \(n_t\) training example pairs \((x, y)_t\).
Real case FNN - (basecase)¶
Regression¶
Similarly to the simple case, the cost \(J\) is shown in equation (1) from the comparison of the training targets to all the predictions made by the network. As mentioned, the simplest cost for regression is the average norm 2 of the difference between training targets and estimations.
For clarity, the averaging over \(t\) will be dropped for the time being. Since the gradient of a sum is the sum of the gradients, let us write down the equation for a single training example, then at the very end, the averaging over training data will be seamlessly reintroduced.
A value of the node \(a_j\) is obtained in two steps from all the values of the previous layer nodes \(a_k\). First one obtains the intermediate variable \(z_j\) using the weights \(w_{jk}\) and bias \(b_j\) of the edge leading to the node \(a_j\) from the \(k\) layer, then the activation function \(\sigma(z_j)\) is used, see equations (3):
The derivation chain rule is applied to compute the gradient terms for the last layer, see equations (3) and (4). The equations are the same as the Simple case because only a single term of the sum has a \(w_{jk}\) term in it for a specific value of \((j, k)\) as illustrated in figure Real case FNN - (w_jk).
Real case FNN - (\(w_{jk}\))¶
To obtain the node \(a_k\) from all the nodes \(a_{k\ell}\):
The derivation chain rule is also applied to compute the gradient terms for the second to last layer, see equations (7) and (8). However, as illustrated in figure Real case FNN - (w_kl), this time all the nodes \(a_j\) are affected by a change in \(w_{k\ell}\) and a summation is required.
Real case FNN - (\(w_{k\ell}\))¶
From this point, the method should seem straightforward. Equation (7) shows how to obtain the sensitivity \(\cfrac{\partial J}{\partial z_k}\) of the cost to a variation of \(z_k\) from the sensitivities of the forward next layer \(\cfrac{\partial J}{\partial z_j}\). This step is repeated from right to left to compute the cost gradient component for every weight and bias.
For example, the next step is to obtain the gradient terms relative to the \(\ell\) layer. Figure Real case FNN - (w_lm) shows that a change in the \(w_{\ell m}\) coefficients impacts the values of all nodes of all forward layers. However, the sensitivities \(\cfrac{\partial J}{\partial z_k}\) are already known. The main task is only to obtain the \(\cfrac{\partial J}{\partial z_\ell}\) from them, see equations (9) and (10).
Real case FNN - (\(w_{\ell m}\))¶
Once all the gradients components are calculated, they must be averaged on several inputs to form a correction for the gradient descent.
Multi-class classification¶
For multi-class classification, even if the loss function seems very different, the final expression for the gradient is very similar. With the loss function for a single training example, a single non zero term corresponding to the correct class in the following sum:
With \(j^*\) the index of the correct class, the chain rule for the last layer is:
This expression is similar as the one obtained for regression for a linear last layer.
Note
Both for regression or multi-class classification, the gradient is null when the estimation meets the target exactly: if the prediction is perfect, no need for a correction.
Hint
Concerning the implementation of these type of expression:
try to identify matrix multiplication disguised in index notation. (then use
dot)remember that you can always transpose a matrix if the summation is carried along the row index.
take advantage of numpy element-wise operations.
take advantage of numpy broadcasting rules.
a 1d array can be converted into a 2d array with one size equal to 1 using
np.newaxis.
Exercise (easy)
Lower case symbols are 1d array; upper case symbols are 2d array. Implement concisely the following equations (use non square 2d arrays):
\(w_i = u_i v_i\)
\(v_i = \sum_j u_j A_{ij}\)
\(v_j = \sum_i u_i A_{ij}\)
\(B_{ij} = A_{ij} u_j\)
\(B_{ij} = A_{ij} u_i\)
\(A_{ij} = u_i v_j\)
Correction
1"""few examples of basics numpy operations:
2+ element wise
3+ matrix
4+ broadcast"""
5
6import numpy as np
7
8
9# 1
10u = np.array([1, 2])
11v = np.array([3, 4])
12w = u * v # element-wise
13print(u, v, w, "\n", sep="\n")
14
15# 2
16A = np.array([[1, 2], [3, 4], [5, 6]])
17u = np.array([1, 2])
18v = A.dot(u) # matrix
19print(A, u, v, "\n", sep="\n")
20
21# 3
22A = np.array([[1, 2], [3, 4], [5, 6]])
23u = np.array([1, 2, 3])
24v = A.T.dot(u)
25print(A, u, v, "\n", sep="\n")
26
27# 4
28A = np.array([[1, 2], [3, 4], [5, 6]])
29u = np.array([1, 2])
30B = A * u # broadcast
31print(A, u, B, "\n", sep="\n")
32
33# 5
34A = np.array([[1, 2], [3, 4], [5, 6]])
35u = np.array([1, 2, 3])
36B = (A.T * u).T # broadcast
37print(A, u, B, "\n", sep="\n")
38
39# 6
40u = np.array([1, 2])
41v = np.array([3, 4, 5])
42A = v * u[:, np.newaxis] # broadcast
43Abis = u[:, np.newaxis].dot(v[np.newaxis, :])
44print(u, v, A, Abis, "\n", sep="\n")
Exercise (difficult)
How would you calculate \(\cfrac{\partial a_j}{\partial x_i}\) (first, for a single \(j, i\) pair, then for any \(j\) and a given \(i\))? How to generalize to the entire jacobian (any \(j, i\))?
Draw by hand the shape of every operation required to implement the jacobian with matrix product, transpose and broadcast.
Analyze the following code and map every operation to the previous question.
1 def jacobian(self, input_: Vector) -> Array2D:
2 self.infer(input_)
3 jacobian: Array2D = np.eye(self.nb_nodes_per_layer_including_input[0])
4 for ind_layer in range(0, self.nb_layers):
5 layer, _, activation_derivative = (
6 self.get_layer_activation_activation_derivative(ind_layer)
7 )
8 jacobian = (
9 layer.weights.dot(jacobian).T
10 * activation_derivative(layer.z) # broadcast
11 ).T
12
13 return jacobian
Correction
When reaching the first layer, the \(\cfrac{\partial a_m}{\partial x_i}\) is simpler:
As a result, if an inference has been performed for a given input \(x\), then all the \(z\) and \(a\) are up to date. Then, the calculation looks a little like an inference:
One can just as easily obtain the full jacobian matrix if one considers the first line as a matrix for all \(m\) and \(i\). When only linear activation functions are used, the gradient is logically found to be the product of every weight matrices.
Jacobian¶
The only trick is to start by the
jacobianvariable with the identity matrix, this way every layer is treated the same way:weights dot current jacobian
transpose
broadcast the activation derivative
transpose back
repeat
Exercise (difficult)
The goal is to implement the to following methods:
1 def parameter_gradient(
2 self, inputs: Sequence[Vector], targets: Sequence[Vector]
3 ) -> tuple[list[Array2D], list[Vector]]:
4 """The loss gradient with respect to the parameters: either weights or biases.
5
6 dJ/dz of the last layer is first calculated.
7 The detail of this computation depends on the loss function itself: usually
8 either multi-class or regression. It is performed in the last_layer_dJdz method.
9
10 The dJdz of the last layer allows to obtain the dJdb and dJdw of the last layer:
11 dJ/dw_jk = dJ/dz_j a_k with index k on the left and j on the right
12 (which can be obtained by broadcast
13 a_0 ... a_k ...
14 dJ/dz_0
15 ...
16 dJ/dz_j
17 ...
18 )
19
20 Then, using back-propagation, every layer can be obtained from one before last to first:
21 dJ/dw_kl = [ sum_j [w_jk s'(z_k) ] dJ/dz_j ] a_l with index k on the left and j on the right
22
23 c_jk = w_jk s'(z_k) simple broadcasting
24
25 sum_j c_jk dJ/dz_j is almost a dot product, requires a transpose -> d = c^T . dJ/dz
26
27 dJ/dw_kl = c_k a_l can be obtained by broadcast similar as the one for the last layer
28
29 At the end, the sum of the gradients for every case of the train set is divided by
30 the number of cases of the train set to get the average gradient.
31 """
32 ...
1 def fit(
2 self,
3 inputs: Sequence[Vector],
4 targets: Sequence[Vector],
5 learning_rate: float = 0.1,
6 nb_iter_gradient: int = 1_000,
7 regularization: float = 0.0,
8 ) -> None:
9 """Gradient descent algorithm on both the weights and biases, no stopping criteria,
10 no stochastic pooling.
11
12 The learning_rate is for the gradient from the train set.
13
14 The regularization is the lambda_r of the basic regularization:
15 J_r = lambda_r/2 (sum_all w^2 + sum_all b^2)
16 """
17 ...
The first implements the back-propagation algorithm; the second the gradient descent algorithm. Do not bother to implement a stopping criteria in the gradient descent, use a fixed number of iterations. Do not bother to implement stochastic gradient descent either. Some helper methods may help to factorize and shorten the code:
1 def init_parameters_gradient_zero(self) -> tuple[list[Array2D], list[Vector]]:
2 """initialize the arrays for the weight and biases gradients to zero
3 with the correct dimension to be able to compute the mean gradients
4 accross the train set."""
5 ...
1 def get_layer_activation_activation_derivative(
2 self, ind_layer: int
3 ) -> tuple[Layer, FuncVector, FuncVector]:
4 """helper function, code factorisation"""
5 ...
1 def get_left_a_or_input(self, ind_layer: int, input_: Vector) -> Vector:
2 """helper function, the FNN does not own the input,
3 but uses it when left nodes of the first layer are required for an operation"""
4 ...
Finally, to adapt for regression or multi-class classification:
1 @staticmethod
2 def last_layer_dJdz(
3 layer: Layer,
4 activation: FuncVector,
5 activation_derivative: FuncVector,
6 target: Vector,
7 ) -> Vector:
8 """Depending on the last layer activation, one assumes the loss is:
9 + multi-class cross entropy for softmax
10 + mean square error for linear"""
11 ...
Hints 1
Start by drawing by hand every operation involved in the back-propagation clearly showing transpose, matrix multiplication and broadcast.
The back-propagation goes from last layer to first layer. The trick is to propagate the successive \(\cfrac{\partial J}{\partial z}\).
Treat the last layer separately to get the \(\cfrac{\partial J}{\partial z_j}\)
in last_layer_dJdz; you can use the last layer activation to determine
if this is a regression or a multi-class classification.
For the multi-class classification case the target which one-hot encoded makes the \(\cfrac{\partial J}{\partial z_j}\) easy to implement with an element-wise operation.
To compute the mean gradient for every training example, a loop over this examples and some arrays and vectors to cumulate all the individual gradients are useful.
Hints 2
\(\cfrac{\partial J}{\partial z_j}\) is an element-wise operation between
2 Vector (that is the reason why the activations and activation derivatives
were implemented vectorized).
\(\cfrac{\partial J}{\partial w_{jk}}\) is a broadcast between a vertical 1 column 2d array
and a 1d vector \(a_k\) in order to get the \(k\) index along the column.
The result is a Array2d.
Hints 3
The \(\cfrac{\partial J}{\partial z_k}\) operation is a matrix product between a matrix \(w_{jk} \cdot\sigma'(z_k)\) and the vector \(\cfrac{\partial J}{\partial z_j}\) which has just been calculated previously. The operation \(w_{jk} \cdot\sigma'(z_k)\) is a simple broadcast.
The operation \(\cfrac{\partial J}{\partial w_{kl}}\) is similarly as previous hint,
a broadcast between a vertical 1 column 2d array
and a 1d vector \(a_l\) in order to get the \(l\) index along the column.
The result is a Array2d.
Correction
src¶1 def init_parameters_gradient_zero(self) -> tuple[list[Array2D], list[Vector]]: 2 """initialize the arrays for the weight and biases gradients to zero 3 with the correct dimension to be able to compute the mean gradients 4 accross the train set.""" 5 biases_all: list[Vector] = [] 6 weights_all: list[Array2D] = [] 7 # init to zero for cumsum over training examples 8 for ind_layer in range(0, self.nb_layers): 9 layer = self.layers[ind_layer] 10 weights_all.append( 11 np.zeros((layer.nb_nodes_right, layer.nb_nodes_left), dtype=FLOAT) 12 ) 13 biases_all.append(np.zeros(layer.nb_nodes_right, dtype=FLOAT)) 14 return weights_all, biases_allsrc¶1 def get_layer_activation_activation_derivative( 2 self, ind_layer: int 3 ) -> tuple[Layer, FuncVector, FuncVector]: 4 """helper function, code factorisation""" 5 layer: Layer = self.layers[ind_layer] 6 activation: FuncVector = self.activations[ind_layer] 7 activation_derivative: FuncVector = self.activation_derivatives[activation] 8 return layer, activation, activation_derivativesrc¶1 def get_left_a_or_input(self, ind_layer: int, input_: Vector) -> Vector: 2 """helper function, the FNN does not own the input, 3 but uses it when left nodes of the first layer are required for an operation""" 4 return input_ if ind_layer == 0 else self.layers[ind_layer - 1].asrc¶1 @staticmethod 2 def last_layer_dJdz( 3 layer: Layer, 4 activation: FuncVector, 5 activation_derivative: FuncVector, 6 target: Vector, 7 ) -> Vector: 8 """Depending on the last layer activation, one assumes the loss is: 9 + multi-class cross entropy for softmax 10 + mean square error for linear""" 11 error: Vector = layer.a - target 12 if activation == softmax: # multi-class cross entropy gradient 13 dJdz: Vector = error * target 14 elif activation == linear: # regression mean square gradient 15 dJdz: Vector = error 16 else: # regression mean square gradient, other activations 17 dJdz: Vector = error * activation_derivative(layer.z) 18 return dJdzsrc¶1 def parameter_gradient( 2 self, inputs: Sequence[Vector], targets: Sequence[Vector] 3 ) -> tuple[list[Array2D], list[Vector]]: 4 """The loss gradient with respect to the parameters: either weights or biases. 5 6 dJ/dz of the last layer is first calculated. 7 The detail of this computation depends on the loss function itself: usually 8 either multi-class or regression. It is performed in the last_layer_dJdz method. 9 10 The dJdz of the last layer allows to obtain the dJdb and dJdw of the last layer: 11 dJ/dw_jk = dJ/dz_j a_k with index k on the left and j on the right 12 (which can be obtained by broadcast 13 a_0 ... a_k ... 14 dJ/dz_0 15 ... 16 dJ/dz_j 17 ... 18 ) 19 20 Then, using back-propagation, every layer can be obtained from one before last to first: 21 dJ/dw_kl = [ sum_j [w_jk s'(z_k) ] dJ/dz_j ] a_l with index k on the left and j on the right 22 23 c_jk = w_jk s'(z_k) simple broadcasting 24 25 sum_j c_jk dJ/dz_j is almost a dot product, requires a transpose -> d = c^T . dJ/dz 26 27 dJ/dw_kl = c_k a_l can be obtained by broadcast similar as the one for the last layer 28 29 At the end, the sum of the gradients for every case of the train set is divided by 30 the number of cases of the train set to get the average gradient. 31 """ 32 # first store the sum for all training example of gradients 33 # then at the end, the mean gradient 34 gradients_weights: list[Array2D] 35 gradients_biases: list[Vector] 36 gradients_weights, gradients_biases = self.init_parameters_gradient_zero() 37 38 for input_, target in zip(inputs, targets): 39 # update all a and z for given training example 40 self.infer(input_) 41 42 ## last layer 43 ind_layer = self.nb_layers - 1 44 layer, activation, activation_derivative = ( 45 self.get_layer_activation_activation_derivative(ind_layer) 46 ) 47 left: Vector = self.get_left_a_or_input(ind_layer, input_) 48 49 dJdz: Vector = self.last_layer_dJdz( 50 layer, activation, activation_derivative, target 51 ) 52 53 gradients_biases[ind_layer] += dJdz 54 dJdw: Array2D = dJdz[:, np.newaxis] * left # broadcast 55 gradients_weights[ind_layer] += dJdw 56 57 dJdz_prev = dJdz 58 59 ## layers from one before last included, to first 60 for ind_layer in range(self.nb_layers - 2, -1, -1): 61 layer, _, activation_derivative = ( 62 self.get_layer_activation_activation_derivative(ind_layer) 63 ) 64 left: Vector = self.get_left_a_or_input(ind_layer, input_) 65 dJdz = ( 66 self.layers[ind_layer + 1].weights 67 * activation_derivative(layer.z) # broadcast 68 ).T.dot(dJdz_prev) 69 70 gradients_biases[ind_layer] += dJdz 71 dJdw = dJdz[:, np.newaxis] * left # broadcast 72 gradients_weights[ind_layer] += dJdw 73 74 dJdz_prev = dJdz 75 76 # from sum to average 77 nb_training = len(inputs) 78 for ind_layer in range(0, self.nb_layers): 79 gradients_weights[ind_layer] /= nb_training 80 gradients_biases[ind_layer] /= nb_training 81 82 return gradients_weights, gradients_biasessrc¶1 def fit( 2 self, 3 inputs: Sequence[Vector], 4 targets: Sequence[Vector], 5 learning_rate: float = 0.1, 6 nb_iter_gradient: int = 1_000, 7 regularization: float = 0.0, 8 ) -> None: 9 """Gradient descent algorithm on both the weights and biases, no stopping criteria, 10 no stochastic pooling. 11 12 The learning_rate is for the gradient from the train set. 13 14 The regularization is the lambda_r of the basic regularization: 15 J_r = lambda_r/2 (sum_all w^2 + sum_all b^2) 16 """ 17 if self.bad_input_or_target_size( 18 self.nb_nodes_per_layer_including_input[0], inputs 19 ): 20 raise IncompatibleDimensions("The size of the input is incompatible.") 21 if self.bad_input_or_target_size( 22 self.nb_nodes_per_layer_including_input[-1], targets 23 ): 24 raise IncompatibleDimensions("The size of the target is incompatible.") 25 26 for _ in range(nb_iter_gradient): 27 gradients_weights, gradients_biases = self.parameter_gradient( 28 inputs, targets 29 ) 30 for ind_layer in range(self.nb_layers): 31 self.apply_regularization(regularization) 32 self.layers[ind_layer].weights -= ( 33 learning_rate * gradients_weights[ind_layer] 34 ) 35 self.layers[ind_layer].biases -= ( 36 learning_rate * gradients_biases[ind_layer] 37 )