Derivation and Implementation of a Dense Neural Network on MNIST
The content below was generated entirely by machine translation. Please verify its accuracy. If anything is unclear, consult the Chinese source version.
upd@2022/11/5: Added the concrete implementation and corrected several notation errors in the derivation.
upd@2024/6/8: Corrected a subscript error in the derivation.
The main purpose of backpropagation is to calculate the partial derivatives of the neural-network error with respect to parameters such as biases and weights, allowing gradient descent to be performed. The first half of this article derives the algorithm; the second half uses a fully connected neural network and the MNIST dataset to implement handwritten-digit recognition.
This algorithm was still very difficult for me to understand, so I wrote these notes to prevent myself from forgetting it. Another reason is that neural-network formulas have so many superscripts and subscripts that it is very easy to write one incorrectly in a physical notebook. If you do not yet have a basic understanding of neural networks, I recommend 3Blue1Brown’s neural-network video series.
I must also say that MqCreaple is truly incredible: after watching the videos, he directly derived every formula by hand—even more astonishingly, he managed to teach someone like me.
Formula Derivation
Notation and Language Conventions
- denotes the activation function.
- denotes the final error.
- denotes the error function.
- denotes the neural network’s prediction, while denotes the actual answer.
- denotes a neural-network layer; a smaller value means the layer is closer to the input layer.
- denotes an edge from node in layer to node in layer .
- denotes the bias of node in layer .
- denotes the output of node in layer before applying the activation function.
- denotes , the node’s output after applying the activation function.
- denotes the number of nodes in layer .
- An underline beneath a variable indicates that it is treated as a constant, such as .
- A layer “before” another layer has a smaller , and a layer “after” it has a larger .
A Neural Network with One Node per Layer
First consider the simplest fully connected neural network, in which every layer has only one node. The following diagram represents the process of calculating a single node’s output . The variable at the starting point of an arrow and the corresponding function produce the variable at the arrow’s endpoint.
graph TB
alm1["a(l-1)"] & w & b --> z --> al["a(l)"] --> Error
y-->Error
Written as functions, the process is:
To perform gradient descent on the weight according to the error, we need the partial derivative of the error with respect to the weight:
We use a partial derivative because the calculation of depends on three variables, while we want to know how changing affects the error.
When taking the partial derivative, assume that the other variables are constants. Only one variable changes, along with the variables directly affected by it—in this case, the chain . Constants are underlined in the following expression:
We can now apply the chain rule:
In another form, which will be more convenient later:
The intermediate partial derivatives in the chain rule can then be calculated. Assuming the error function is squared error, the expression becomes:
Be careful not to reverse . For example, when is too large, we also want the derivative to be large so that the value being adjusted can have the derivative subtracted from it.
The preceding calculation gives the error’s partial derivative with respect to the weight. For the bias and the previous layer’s output, simply replace in the formula. In other words, let the previous layer’s output or this layer’s bias affect instead of the weight.
For , use:
For , use:
Now consider the following network:
graph TB
al["a(l)"] & wlp["w(l + 1)"] & blp["b(l + 1)"] --> zlp["z(l + 1)"] --> alp["a(l + 1)"] --> ...many other layers
__["w(l + 2)"] & _["b(l + 2)"]-->...many other layers
Here, the layer after does not connect directly to the error function; many layers intervene. We can no longer calculate directly, and therefore cannot directly calculate the partial derivatives for and , because lies many layers later. This is where backpropagation is required.
We know:
The expression shows that for an earlier layer can be derived from a later layer. Before calculating partial derivatives for weights and biases, start from the output layer and propagate backward one layer at a time.
Multiple Nodes per Layer
The backpropagation process in the preceding example is clear and involves no linear algebra. In a real neural network, however, every layer contains multiple nodes:
graph LR
l1["(l-1)1"] & l2["(l-1)2"] & l3["(l-1)3"] ---> lp1["l1"] & lp2["l2"] & lp3["l3"]
Partial Derivative of the Error with Respect to a Weight
denotes an edge connecting node in layer to node in layer . How do we calculate ?
We can still use the original formula, because a layer with multiple nodes is fundamentally composed of multiple single-node layers. We must, however, pay attention to the subscripts:
Every variable related to layer uses subscript , such as . Intuitively, when a unit of weight changes, a larger input from the previous layer has a larger effect on the final error function. Variables related to layer use subscript .
Because uses the same subscript throughout, call it to make the matrix-operation formula easier to write.
Rewriting the formula gives:
In the matrix form of , increases by row and increases by column. The derivative matrix is:
This matrix is equal to:
We can therefore use a matrix-operation library such as NumPy to accelerate the calculation.
Partial Derivative of the Error with Respect to a Bias
This is relatively simple because , as shown earlier:
The error derivative with respect to the bias equals the used above. Implementations therefore generally calculate this first, then substitute into the weight formula.
Partial Derivative of the Error with Respect to the Previous Layer’s Input
Look again at the multi-node network, now focusing on the effect of a single node in layer on the later nodes:
graph LR
l1["(l-1)1"] ===> lp1["l1"] & lp2["l2"] & lp3["l3"]
can affect every . If layer is regarded as a function that receives values and outputs values , then every input variable is changing. The result is not a partial differential but a total derivative[1].
By the definition of a total derivative, add the partial derivative with respect to every parameter. In this example:
The factor needs careful handling. Remember that is the edge connecting node in layer and node in layer .
Since
we obtain
The factor was explained earlier: it equals , the error derivative with respect to the bias.
Rewriting the complete expression gives:
Now consider how to obtain with matrix operations. One workable method multiplies and .
We accumulate over subscript . If is placed on the left, its coordinate must increase with the column number; in matrix multiplication , rows of are dotted with columns of . If is placed on the right, its subscript must increase with the row number.
Because in originally increases by row, transpose it. The final result is:
where is a column vector.
Implementation
This section uses the backpropagation algorithm derived above to implement a simple fully connected neural network, then uses that network to recognize handwritten digits in the MNIST dataset.
Data Preprocessing
To be honest, the MNIST dataset is rather troublesome. It uses a binary storage format, so reading its contents takes some work.
The code is as follows
The struct package may look confusing. It is simply a class specialized for processing binary data.
1 | struct.unpack('>II', lfile.read(8)) |
This statement reads two four-byte unsigned integers in big-endian byte order from lfile. In >II, > indicates that the file uses big-endian byte order, while I indicates a four-byte unsigned integer.
The following np.fromfile serves a similar purpose, directly converting the binary file into a NumPy array. No byte order is specified, perhaps because NumPy uses big-endian by default.
Individual pixels in MNIST are integers in . We want floating-point values in , so divide by 255 before returning.
Keeping images in is important because passing a relatively large number to $sigmoid` can cause overflow. Although each layer’s initial weights are randomly generated between -1 and 1, a layer can still sometimes output a large value. Sigmoid is defined as:
If is too small, becomes extremely large. NumPy performs its calculations through C and does not have Python’s built-in arbitrary precision, so such a value naturally causes overflow.
The final preprocessing step converts labels into one-hot form, making it convenient to calculate the gradient of the error over the complete network. np.eye(x) generates an identity matrix, so $np.eye(10)[labels[i]]is naturally the one-hot encoding corresponding tolabels[i]`.
The layer Class
A single neural-network layer is fundamentally a function that receives a vector and outputs a vector. The function depends on many variables, including weights and biases, so we want a class that stores them.
Backpropagation also needs the variables stored in the class. It is best to implement a function that receives the derivative of the error with respect to the current layer’s output, along with other necessary data, and returns the derivative of the error with respect to the previous layer’s output.
Finally, the layer needs an interface for updating its weights and biases. If other layer types use data other than weights and biases, an abstract class can represent the data of different layers.
These requirements give the following abstract layer class. Every parameter name follows the mathematical formulas above; refer to the derivation if any are unclear.
1 | # Location in the project: ./src/layer.py |
Here, util.Dfunc represents a differentiable function and is defined as follows:
1 | # Location in the project: ./src/util.py |
A fully connected layer can be implemented as follows:
1 | # Location in the project: ./src/layer.py |
Except for get_derivatives, the functions are relatively easy to understand. The following roughly explains that function.
The formula for the error derivative with respect to the preceding layer is:
In the implementation, this corresponds to:
1 | DE_over_prev_a: NDArray = np.matmul(self.wts.T, Dbias) |
Here, Dbias equals :
This corresponds to:
1 | Dbias : NDArray = DE_over_cur_a * self.activ.Df(cur_z) |
The formula for the derivative of the error with respect to weights is:
It corresponds to:
1 | Dweight = np.matmul( |
The neu_net Class
The network class connects different layers, passing the output of one layer as the input to the next. It can also backpropagate starting from the error function.
Initialization Function
1 | # Location in the project: ./src/net.py |
There are two ways to initialize the network. Individual layer objects can be supplied directly and combined by the network class, or a list containing the node count of each layer can be supplied so that the initializer automatically creates the corresponding fully connected network.
Output Function
1 | def get_predict(self, ipt : NDArray): |
The neural network’s first layer is special: it does not connect to the output of a preceding layer, but directly uses ipt, so it must be handled separately.
Backpropagation
1 | def bp(self, ipt: NDArray, label: NDArray, lrate: float): |
The main purpose here is to call get_derivatives on every layer and obtain the derivatives with respect to the outputs, weights, and biases of different layers.
There are two special cases. First, the error derivative with respect to the final layer must be obtained from the error function and label:
1 | DE_over_a[-1] = self.err.Df(label, lay_a[-1]) |
The error derivatives with respect to the first layer’s weights and biases can only be obtained from the input image:
1 | lay_Db[0] = self.lays[0].activ.Df(lay_z[0]) * DE_over_a[0] |
Results
The accuracy reaches 96%, which is quite good; training took a little over a minute. Of course, the training method still has substantial room for optimization, and I did not tune the parameters very much.
- 1.https://zh.wikipedia.org/wiki/全微分 ↩
- 2.↩
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19# Location in the project: ./src/util
def load_mnist(path: str, pref: str = "train"):
"""
path: dataset path
data_type: dataset-name prefix (train or t10k)
"""
label_path = os.path.join(path, "{}-labels.idx1-ubyte".format(pref))
img_path = os.path.join(path, "{}-images.idx3-ubyte".format(pref))
with open(label_path, 'rb') as lfile: # rb means read binary.
magic, n = struct.unpack('>II', lfile.read(8))
labels = np.fromfile(lfile, dtype=np.uint8)
with open(img_path, 'rb') as ifile: # ifile means image file.
magic, num, rows, cols = struct.unpack('>IIII', ifile.read(16))
images = np.fromfile(ifile, dtype=np.uint8).reshape(
len(labels), 28 * 28)
label_one_hot = np.zeros((len(labels), 10), dtype=int)
for i in range(len(labels)):
label_one_hot[i] = np.eye(10)[labels[i]]
return label_one_hot, images / 255.0 - 2.Adapted from https://zhuanlan.zhihu.com/p/120378080 ↩






![[Stanford CS144] Lab 4 Record](/img/CS144/tcp%E7%8A%B6%E6%80%81%E6%B5%81%E8%BD%AC%E5%9B%BE.jpg)