Project intention
Why rebuild an MLP without deep-learning libraries?
The intention of this project is simple: for friends who have just started learning neural networks, it is often difficult to see what forward propagation and backward propagation are really doing. A framework can train a model in a few lines, but it can also hide the chain rule, the gradients, the shapes of matrices, and the reason parameters change.
So this project implements a multilayer perceptron with explicit derivation and explicit Python code. The network uses Fashion-MNIST as the dataset, and contains two derivation paths: sigmoid with mean squared error, and softmax with cross entropy.
Build the network once by hand, and the equations stop being symbols on a page. They become the code path of learning itself.
1 · Introduction / 2.1 · Forward propagation
Forward propagation turns input information into a prediction.
This is an introduction of the theory of MLP, illustrated by derivation with both MSE using sigmoid and cross entropy using softmax. Forward propagation can be understood as the process of network prediction: it transforms input information into classification results.
Suppose the input layer information is \([x_1, x_2, x_3]\). For layer \(l\), \(L_l\) represents all neurons of the layer, the output is \(y_l\), the output of the \(j\)-th node is \(y_l^{(j)}\), the input of that node is \(u_l^{(j)}\), the weight matrix connecting layer \(l\) and layer \(l-1\) is \(W_l\), and the weight from the \(i\)-th node of the previous layer to the \(j\)-th node of layer \(l\) is \(w_l^{ji}\).
To make this easy to code, the first equation can be written as a matrix expression:
Extending the forward propagation calculation process of the second layer to any layer in the network gives:
Here, \(f(\cdot)\) is the activation function, and \(b_l^{(j)}\) is the bias of the \(j\)-th node in layer \(l\).
2.2 · Backward propagation
Backward propagation asks how each parameter changed the loss.
After the basic model is built, the process of training is to update the model parameters. Due to the multi-layer network structure, it is not possible to directly update the parameters of the hidden layer by the loss function. Instead, the loss is propagated from the top layer to the bottom layer to estimate the parameters.
Suppose there are multiple neurons in the output layer of the multilayer perceptron, and each neuron corresponds to a label. The input sample is \(x=[x_1,x_2,\ldots,x_n]\), and the label is \(t\). For the output layer, this note includes two loss functions:
The loss function determines how the output layer parameters are updated. In order to improve training accuracy, the loss function with sigmoid as the output activation is defined as MSE, while the loss function with softmax as the output activation is defined as cross entropy. To minimize the loss, gradient descent is used.
The following three derivatives are easy to derive from the definition of the forward pass:
Therefore, the parameter gradients become:
Each node in the next layer is related to all nodes from the former layer. The loss function can therefore be considered as a function of the input of each node in the next layer:
Node sensitivity
The gradient becomes easier once the node sensitivity is named.
For better understanding of calculation, define \(\delta=\partial E/\partial u\) as the rate of change of error to input. This is node sensitivity. Therefore, the node sensitivity of the \(j\)-th node from layer \(l\) is:
Applying node sensitivity to the derivation of the loss function simplifies the upstream error term:
For the output layer, there is no next layer to consider. The output of the final layer is directly related to the error. Therefore, after multiplying by \(f'(u_l^{(j)})\), the sensitivity can be written as:
The gradient of the loss function with respect to each parameter is:
To make it suitable for every node and easy to code, the matrix form is \(\partial E/\partial W_l = \delta_l y_{l-1}^T\) and \(\partial E/\partial b_l = \delta_l\), where:
The symbol \(\circ\) represents multiplication of corresponding elements in a matrix or vector. The update equations of weights from each layer are:
2.3 · Derivation of sigmoid and MSE
Sigmoid turns the derivative into a reusable local term.
The sigmoid function can be simplified as:
Therefore, the derivation of the sigmoid function is:
Referring to the \(E_{MSE}\) equation above, the derivative of the MSE loss function is:
In the sigmoid implementation of this project, the output layer sensitivity becomes the MSE derivative multiplied by the sigmoid derivative:
sigma_out = (layer_2 - target) * (layer_2 * (1 - layer_2))
2.3.1 · Derivation of softmax and cross entropy
Softmax plus cross entropy collapses into a clean error term.
The derivative of softmax and cross entropy can be considered as:
For the first derivative \(\partial E_j/\partial y_j\):
As for the softmax part, there are two conditions. When \(i=j\):
However, if \(i\neq j\):
Combining the equations above, the final derivation of softmax and cross entropy is:
There is only one target value \(t_i\) equal to 1, therefore \(\sum_j t_j=1\). In the end, the final derivative is:
In the softmax implementation of this project, this appears directly as the output sensitivity:
sigma_out = layer_2 - target
2.3.2 · Problems of coding sigmoid and softmax
The final step is numerical stability.
The program overflowed when designing sigmoid and softmax methods in this project. To solve these problems, the properties of the two activation functions were studied and the equations were rewritten into numerically safer forms.
The original sigmoid form is \(\sigma(z)=1/(1+e^{-z})\). When the input is negative with a large absolute value, computing \(e^{-z}\) directly can overflow. To solve this, the sigmoid code is modified as follows:
def sigmoid_function(input):
fz = []
input = input.tolist()
for num in input:
if num >= 0:
fz.append(1.0 / (1 + math.exp(-num)))
else:
fz.append(math.exp(num) / (1 + math.exp(num)))
output = np.array(fz)
return output
Similarly, the original form of softmax is:
If the input signal is too large, the program can overflow. To avoid this, the formula can be modified without changing the result of the activation function:
Here, \(C\) is the maximum number in the input array. The corresponding code is:
def softmax_function(input):
max_input = np.max(input)
input = np.exp(input - max_input)
sum_input = np.sum(input)
output = input / sum_input
return output