深刻理解神经网络计算原理

笔者初入深度学习先是使用TensorFlow后转PyTorch,但都是在框架上进行开发,只能理解各种网络结构的“头口”原理。在学习的中后期或者是要做落地的时候,就会困扰一个非常常见的问题——我们的网络到底需要多少的计算资源?我们暂且将计算资源在两方面考虑:显存和浮点计算次数。这就需要对网络计算过程的底层有透彻直接的认识,也就是本文的重点

很多厉害的大佬可以根据经验、直觉直接判断哪些网络可以在嵌入式平台上跑,但是笔者还是偏爱将过程量化

全连接网络

梯度下降

为了方便讨论,我们选取单隐藏层的全连接网络进行讨论。首先,声明该网络的输入为:

$$
x_0 = \begin{bmatrix}
x_0^{(1)} \\
x_0^{(2)} \\
\vdots \\
x_0^{(m_0)}
\end{bmatrix} \sim [m_0,1]
$$

本文中所有公式中~后的为矩阵、向量的维度。然而,在训练过程中,我们通常一次使用几个样本一起训练网络(mini-batch),那么一次输入网络的数据就是:

$$
X_0 = \begin{bmatrix}
x_{0,1} ,
x_{0,2} ,
\cdots ,
x_{0,N}
\end{bmatrix} \sim [m_0,N]
$$

现在规定一层全连接的计算参数:

$$
权重W_1 = \begin{bmatrix}
w_1^{(0,0)},w_1^{(0,1)},\cdots,w_1^{(0,m_0)} \\
\vdots \\
w_{1}^{(m_1,0)},w_1^{(m_1,1)},\cdots,w_1^{(m_1,m_0)}
\end{bmatrix} \sim [m_1,m_0]
\\
偏置B_1 = \begin{bmatrix}
b_1^0, \\
b_1^2, \\
\vdots \\
b_1^{m_1}
\end{bmatrix} \sim [m_1,1]
$$

同理其他层的参数维度也是:

$$
W_l \sim [m_l,m_{l-1}],B_l \sim [m_l,1]
$$

则上图中的单层全连接网络,我们可以将正向传播过程表示以线性计算和非线性激活两部分拆分为:

$$
Z_1 = W_1\cdot X_0 + B_1
\\ \sim [m_1,N] = [m_1,m_0]\cdot [m_0,N] + [m_1,1]
\\
Y_1 = f_1(Z_1) \sim [m_1,N]
\\
X_1 = Y_1
\\
Z_2 = W_2\cdot X_1 + B_2 = \begin{bmatrix}
z_{2,1} ,
z_{2,2} ,
\cdots ,
z_{2,N}
\end{bmatrix}
\\ \sim [m_2,N] = [m_2,m_1]\cdot [m_1,N] + [m_2,1]
\\
Y_2 = f_2(Z_2) \sim [m_2,N]
\\
\hat{Y} =Y_2 = \begin{bmatrix}
\hat{y_0} ,
\hat{y_1} ,
\cdots ,
\hat{y_N}
\end{bmatrix},
\hat{y_i} = \begin{bmatrix}
\hat{y_i}^{(0)} \\
\hat{y_i}^{(1)} \\
\vdots \\
\hat{y_i}^{(m_2)}
\end{bmatrix} \sim [m_2,1]
$$

上面在线性层计算的时候,读者会发现做加分的时候,维度不匹配。不用担心,在我们代码中计算的时候,矩阵库会自动帮我们扩展(参见Numpy或者TensorFlow的广播概念)

那么,现在我们对该网络进行训练,在正向传播后需要作反向传播来计算梯度。首先,要定义一下网络的损失(Loss)函数:

$$
L(W,B) = \frac{1}{N} \sum^N_{i=1} loss(\hat{y_i},y_i)
$$

我们的目标就是根据梯度不断更新网络参数,使得该函数的结果最小。则对各可训练参数的偏导计算:

$$
\frac{\partial L(W,B)}{\partial Z_2} = \frac{\partial L(W,B)}{\partial Y_2} \cdot \frac{\partial Y_2}{\partial Z_2}
$$

$$
= \frac{1}{N} *
\begin{bmatrix}
\frac{\partial loss(\hat{y_1},y_1)}{\partial \hat{y_1}}, \cdots , \frac{\partial loss(\hat{y_N},y_N)}{\partial \hat{y_N}}
\end{bmatrix} *
\begin{bmatrix}
\frac{\partial \hat{y_1}}{\partial z_{2,1}}, \cdots , \frac{\partial \hat{y_N}}{\partial z_{2,N}}
\end{bmatrix}
$$

$$
= \frac{1}{N} *
\begin{bmatrix}
\frac{\partial loss(\hat{y_1},y_1)}{\partial \hat{y_1}} * \frac{\partial \hat{y_1}}{\partial z_{2,1}}
, \cdots,
\frac{\partial loss(\hat{y_N},y_N)}{\partial \hat{y_N}} * \frac{\partial \hat{y_N}}{\partial z_{2,N}}
\end{bmatrix} \\ \sim [m_2,N] * [m_2,N] = [m_2,N]
$$

注意:上式中两次偏导的结果是通过数乘*传递的,而不是矩阵乘法。消化这个比较困难,因为角标太多了,笔者第一次尝试理解也失败了,后面看完实例项目(函数都是具体对象)的反向传播可能会恍然大悟

从上面的式子中,我们可以知道,使用mini-Batch的方式一批批样本进去,最后反向传播的时候,其实可以分成每个单独样本求导,最后作平均求和。所以,我们接下来把其他参数的偏导都先以单个样本的形式求解,最后以Batch进行归纳:

$$
\frac{\partial L}{\partial W_2} = \frac{\partial L}{\partial Z_2} \cdot \frac{\partial Z_{2}}{\partial W_2} = \frac{\partial L}{\partial Z_2} \cdot X_1^T
\\ = \frac{1}{N} \begin{bmatrix}
[\frac{\partial loss(\hat{y_1},y_1)}{\partial \hat{y_1}} * \frac{\partial \hat{y_1}}{\partial z_{2,1}}]\cdot x_{1,1}^T + \cdots + [\frac{\partial loss(\hat{y_N},y_N)}{\partial \hat{y_N}} * \frac{\partial \hat{y_N}}{\partial z_{2,N}}]\cdot x_{1,N}^T
\end{bmatrix}
\\ \sim [m_2,m_1] = [m_2,N] \cdot [m_1,N] ^T
$$

这个地方也许会有人觉得很怪,为什么后面的x向量要作转置?这里笔者是从矩阵相乘的规则去理解。

$$
\frac{\partial L}{\partial B_2} =
\frac{\partial L}{\partial Z_{2}} \cdot \frac{\partial Z_{2}}{\partial B_2} =\frac{\partial L}{\partial Z_{2}} \cdot 1
\\ = \frac{1}{N} \begin{bmatrix}
[\frac{\partial loss(\hat{y_1},y_1)}{\partial \hat{y_1}} * \frac{\partial \hat{y_1}}{\partial z_{2,1}}] + \cdots + [\frac{\partial loss(\hat{y_N},y_N)}{\partial \hat{y_N}} * \frac{\partial \hat{y_N}}{\partial z_{2,N}}]
\end{bmatrix}
\\ \sim [m_2,1]
$$

这样就完成了最后一层的偏导计算,一样的我们继续求前一层偏导。首先是前一层激活输出:

$$
\frac{\partial L}{\partial Y_1} = \frac{\partial L}{\partial Z_{2}} \cdot \frac{\partial Z_{2}}{\partial Y_1} = [\frac{\partial L}{\partial Z_{2}} ^T \cdot W_2 ]^T
\\ =
\\ \sim [m_1,N] = \begin{bmatrix}
[m_2,N]^T \cdot [m2,m_1]
\end{bmatrix} ^ T
$$

以及线性输出偏导:

$$
\frac{\partial L}{\partial Z_1} = \frac{\partial L}{\partial Y_1}\cdot \frac{\partial Y_1}{\partial Z_1} = \frac{\partial L}{\partial Y_1} * f_1’(Z_1)
\\ \sim [m_1,N] = [m_1,N] * [m_1,N]
$$

则可以得到可训练参数的偏导为:

$$
\frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial Z_1}\cdot \frac{\partial Z_1}{\partial W_1} = \frac{\partial L}{\partial Z_1}\cdot X_0^T
\\ \sim [m_1,m_0] = [m_1,N] \cdot [m_0,N] ^ T
$$

$$
\frac{\partial L}{\partial B_1} = \frac{\partial L}{\partial Z_1}\cdot \frac{\partial Z_1}{\partial B_1} = \frac{\partial L}{\partial Z_1}\cdot 1
\\ \sim [m_1,1]
$$

MNIST分类实战

根据上面的推导,我们可以抽象地了解(全连接)网络的原理与计算过程,但全都是公式以及概念,接下来,我们实际操作一下用numpy科学计算库搭建单层全连接神经网络识别MNIST数据集中的数字。

计算过程分析

首先,我们先把上面分析的网络具体参数化,网络的结构定义:

$$
输入层m_0 = 28*28
\\
隐藏(单)层m_1 = 128
\\
输出层m_2 = 10
$$

以及网络的激活函数和损失函数:

$$
f_1(z) = ReLU(z) =
\begin{cases}
z , z>0
\\ 0, z \le 0
\end{cases}
$$

$$
f_2(Z_2)= softmax(Z_2) = \frac{e^{Z_2}}{ \sum^{m_2}_{k=1} e^{ Z_2^{(k)} } }
$$

$$
loss(\hat{y_i},y_i) = -\sum^{m_2}_{k=1} y_i^{(k)} \ln(\hat{y}_i^{(k)})
$$

注意与上面我们推导全连接公式中的符号进行逻辑闭环。第一个激活函数中的变量是小写z,表示实数;第二个激活函数,已经与上面我们推导的输出层符号同步,认真观察,笔者在右上角加的角标在上面推导中是表示第几行,因为这里面的除法其实是一个(BatchSize宽的)矩阵除法!

最困难的第一步,输出层偏导

现在我们推导一下损失函数(交叉熵)的导数:

$$
\frac{\partial loss(\hat{y_i},y_i)}{\partial \hat{y}_i^{(j)}} = y_i^{(j)} \frac{1}{\hat{y}_i^{(j)}}
$$

以及输出层softmax激活函数的导数,但是为了方便理解,我们先推导单个列向量(一个BatchSize宽度)的情况:

$$
f_2( Z_{2,i} ) = softmax( Z_{2,i} )
= \frac{ e^{ Z_{2,i} } }{ \sum^{ m_2 } _ { k=1 } e^{ Z _ { 2,i }^{ (k) } } } =
\begin{bmatrix}
\frac{e^{Z^{(1)} _ {2,i}}}{\sum^{m_2} _ {k=1} e^{Z_{2,i}^{(k)}}}
\\
\vdots
\\
\frac{e^{Z^{(m_2)} _ {2,i}}}{\sum^{m_2} _ {k=1} e^{Z_{2,i}^{(k)}}}
\end{bmatrix}
$$

$$
f’ _ 2 = \frac{\partial}{\partial Z^{(j)} _ {2,i} } \frac{e^{Z^{(k)} _ {2,i}}}{\sum^{m_2} _ {k=1} e^{Z _ {2,i}^{(k)}}} =
\begin{cases}
\frac{e^{Z_{2,i}^{(j)}} \sum_{k=1}^{m_2} e^{Z_{2,i}^{(k)}} - e^{Z_{2,i}^{(j)}}e^{Z_{2,i}^{(j)}}}{ [\sum_{k=1}^{m_2} e^{Z_{2,i}^{(k)}}]^2 } &, j=k
\\
\frac{- e^{Z_{2,i}^{(j)}}e^{Z_{2,i}^{(k)}}}{ [\sum_{k=1}^{m_2} e^{Z_{2,i}^{(k)}}]^2 } &, j \ne k
\end{cases} \\=
\begin{cases}
\frac{e^{Z^{(j)} _ {2,i}}}{ \sum^{m_2}_ {k=1} e^{Z_{2,i}^{(k)}} } [1- \frac{e^{Z^{(j)}_ {2,i}}}{ \sum^{m_2}_ {k=1} e^{Z_ {2,i}^{(k)}} }] = y_ {2,i}^{(j)}[1-y_ {2,i}^{(j)}] &, j=k
\\
-\frac{e^{Z^{(j)}_ {2,i}}}{ \sum^{m_2}_ {k=1} e^{Z_ {2,i}^{(k)}} } \frac{e^{Z^{(k)}_ {2,i}}}{ \sum^{m_2}_ {k=1} e^{Z_ {2,i}^{(k)}} } = -y_{2,i}^{(j)}y_{2,i}^{(k)} &, j \ne k
\end{cases} \\ =
\begin{cases}
\hat{y}_i^{(j)}[1-\hat{y}_i^{(j)}] &, j=k
\\
-\hat{y}_i^{(j)}\hat{y}_i^{(k)} &, j \ne k
\end{cases}
$$

Help Notice:角标i为一批样本中的某个样本

把上面两个偏导合体,就得到了:

$$
\frac{\partial loss(\hat{y_i},y_i)}{\partial Z^{(j)} _ {2,i}}
= -\frac{\partial}{\partial Z^{(j)} _ {2,i}} \sum^{m_2} _ {k=1} y_i^{(k)} \ln(\hat{y}_i^{(k)})
= -\sum^{m_2} _ {k=1} y_i^{(k)} \frac{\partial}{\partial Z^{(j)} _ {2,i}} \ln(\hat{y}_i^{(k)})
\\
= -\sum^{m_2} _ {k=1} y_i^{(k)} \frac{\partial \ln(\hat{y} _ i^{(k)})}{\partial \hat{y} _ i^{(k)}} \frac{\partial \hat{y} _ i^{(k)}}{\partial Z^{(j)} _ {2,i}}
= -\sum^{m_2} _ {k=1} y_i^{(k)} \frac{1}{\hat{y} _ i^{(k)}} \frac{\partial \hat{y} _ i^{(k)}}{\partial Z^{(j)} _ {2,i}}
\\
= -y_i^{(j)} \frac{1}{\hat{y}_i^{(j)}} \hat{y}_i^{(j)}[1-\hat{y}_i^{(j)}] + \sum^{m_2} _ {k\ne j} y_i^{(k)} \frac{1}{\hat{y}_i^{(k)}} \hat{y}_i^{(j)}\hat{y}_i^{(k)}
\\
= -y_i^{(j)} [1-\hat{y}_i^{(j)}] + \sum^{m_2} _ {k\ne j} y_i^{(k)} \hat{y}_i^{(j)} = -y_i^{(j)} + \sum^{m_2} _ {k=1} y_i^{(k)} \hat{y}_i^{(j)}
$$

这时候,我们惊喜地发现!分类事件的概率总和一定是1呀!

$$
\because \sum_k y_i^{(k)} = 1
\\ \therefore
\frac{\partial loss(\hat{y_i},y_i)}{\partial Z^{(j)}_{2,i}} = \hat{y}_i^{(j)} - y_i^{(j)}
$$

那么,我们把上面这个式子,归纳到一个样本的维度去:

$$
\frac{\partial loss(\hat{y_i},y_i)}{\partial Z_{2,i}} = \hat{y}_i - y_i
$$

过程多么的痛苦!结果多么的美妙!🎉🎊✨🎈

$$
\frac{\partial L(W,B)}{\partial Z_2} = \frac{1}{N}(\hat{Y} - Y) \sim [m_2,N]
$$

可训练参数偏导

最后一层的权重和偏置,我们直接使用之前推导的结果就好:

$$
\frac{\partial L}{\partial W_2} = \frac{\partial L}{\partial Z_2} \cdot X_1^T
\\
\frac{\partial L}{\partial B_2} =
\frac{\partial L}{\partial Z_{2}} \cdot \frac{\partial Z_{2}}{\partial B_2} =\frac{\partial L}{\partial Z_{2}} \cdot 1
$$

隐藏层,我们选用的ReLU激活函数的导数为:

$$
f’_1(z) = ReLU’(z) = \begin{cases}
1 , z>0
\\ 0, z \le 0
\end{cases}
$$

那么隐藏的输出偏导为:

$$
\frac{\partial L}{\partial Y_1} = [\frac{\partial L}{\partial Z_{2}} ^T \cdot W_2 ]^T
\\
\frac{\partial L}{\partial Z_1} = \frac{\partial L}{\partial Y_1} * f_1’(Z_1)
$$

这个地方导数展开写太繁琐了,在下面代码实现会更简单直观

所以,隐藏层的导数为:

$$
\frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial Z_1}\cdot X_0^T
\\
\frac{\partial L}{\partial B_1} = \frac{\partial L}{\partial Z_1} \cdot 1
$$

全连接网络实现

根据上面的分析,笔者将单层全连接网络实现为一个类:

这个封装做的还是比较菜的,只是为了感受计算过程而不是长远开发

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
class FC:

def __init__(self,inputS:int=64,hidWidth:int=64,outputS:int=10,active:str="relu",preCacheSize:int = 1):
'''
只支持单隐藏层的全连接网络

Parameters
----------
inputS : int, optional
输入维度. The default is 64.
hidWidth : int, optional
隐藏宽度. The default is 64.
outputS : int, optional
输出维度. The default is 10.
active : string, optional
激活函数. The default is "ReLU".

Returns
-------
None.

'''
self.inputCache = np.zeros(shape=inputS)

self.param = [ # 参数list,每个小list代表一个层的权重和偏置
[], # 空列表,用来统一化id序号
#'''第一层'''
[ np.random.normal(size=(hidWidth,inputS)), #权重
np.zeros(shape=(hidWidth,1))], #偏置
#'''输出层/第二层'''
[ np.random.normal(size=(outputS,hidWidth)),
np.zeros(shape=(outputS,1))],
]

'''随batchSize动态变化,有待优化'''
self.cache = [ # 缓存list,每个小list代表一个层的中间变量
#'''输入层'''
[np.zeros(shape=(inputS,preCacheSize)), # 数据的X
np.zeros(shape=(outputS,preCacheSize))], # 数据的Y
#'''第一层'''
[ np.zeros(shape=(hidWidth,preCacheSize)), # 线性计算结果
np.zeros(shape=(hidWidth,preCacheSize))], # 激活(非线性)结果
#'''输出层/第二层'''
[ np.zeros(shape=(outputS,preCacheSize)),
np.zeros(shape=(outputS,preCacheSize))]
]
self.grad = copy.deepcopy(self.param) # 梯度缓存的大小和可训练参数是一样多的

# 统一激活函数
self.activeFunc = active

print(f"创建全连接网络成功,结构:{inputS}->{hidWidth}->{outputS}")

def __ReLU(self,layerID:int,forward:bool=True):
'''
激活函数,支持正反向
'''
if forward:
self.cache[layerID][1] = np.maximum(0,self.cache[layerID][0]) # 维度会自动扩张
return True
else: # 反向传播
return (self.cache[layerID][1] > 0).astype(dtype=np.int0)

def __Sigmoid(self,layerID:int,forward:bool=True):
'''
激活函数,支持正反向
'''
if forward:
self.cache[layerID][1] = 1/(1+np.exp(np.subtract(0,self.cache[layerID][0])))
return True
else:
return self.cache[layerID][1]*(1-self.cache[layerID][1])

def __softmax(self,inputD:np.ndarray):
'''
激活函数,支持正向
'''
__DEBUG_SOFTMAX__ = False
if __DEBUG_SOFTMAX__:
print(f"inputD : {inputD.shape}")
inputD = np.exp(inputD) # ~ [m_2,N]
if __DEBUG_SOFTMAX__:
print(f"inputD.exp : {inputD.shape}")
sumD = np.sum(inputD,axis=0,keepdims=True) # ~ [1,N]
if __DEBUG_SOFTMAX__:
print(f"sumD : {sumD.shape}")
return inputD/sumD # ~ [m_2,N]

def forward(self,inputX:np.ndarray,inputY:np.ndarray,Debuging:bool = False):
'''
前向传播方法

Parameters
----------
inputX : np.ndarray
输入样本数据,支持mini-Batch.(注意维度为:[dataSize,BatchSize]
inputY : np.ndarray
输入标签数据,支持mini-Batch.(注意维度为:[labelSize,BatchSize]
Debuging : bool, optional
调试打印使能,使用时不开启!. The default is False.

Returns
-------
np.ndarray
网络的输出层数据.

'''
self.cache[0][0] = inputX # 存储数据,用于后续反向传播 ~ [m_0,N]
self.cache[0][1] = inputY # ~ [classes,N]
if Debuging:
print(f"输入数据大小:{self.cache[0][0].shape},输入标签的大小:{self.cache[0][1].shape}")

# 第一个线性层
self.cache[1][0] = self.param[1][0] @ self.cache[0][0] + self.param[1][1]
# ~ [m_1,N] = [m_1,m_0] @ [m_0,N] + [m_1,1]

# 激活
if self.activeFunc == "relu":
self.__ReLU(1,forward=True) # ~ [m_1,N]
elif self.activeFunc == "sigmoid":
self.__Sigmoid(1,forward=True) # ~ [m_1,N]
else:
print("activate error!")
return

# 输出层
self.cache[2][0] = self.param[2][0] @ self.cache[1][1] + self.param[2][1]
# ~ [m_2,N] = [m_2,m_1] @ [m_1,N] + [m_2,1]
# 激活
self.cache[2][1] = self.__softmax(self.cache[2][0])
# ~ [m_2,N]

return self.cache[2][1]

def backward(self):
'''
反向传播
求各个参数层的梯度
'''
'--- 倒1层(softmax输出层) ---'
# 损失函数对 softmax激活前&最后一个线性层后 的偏导
dL_dz = (self.cache[2][1] - self.cache[0][1]) / (self.cache[0][1].shape[1]) # 预测值向量减去真实值的one-hot向量
# [m2,N] = ( [m2,N] - [m2,N] ) /N

# 损失函数对第二层(最后)可训练参数偏导
self.grad[2][0] = dL_dz @ self.cache[1][1].T # 损失对w偏导
# ~ [m2,m1] = [m2,N] @ [m1,N]^T
self.grad[2][1] = np.sum(dL_dz,axis=1,keepdims=True) # 损失对b偏导
# ~ [m_2,1] = sum([m_2,N])

'--- 倒2层 ---'
# 损失函数对 第一个隐藏层的激活输出 的偏导
dL_dz = (dL_dz.T @ self.param[2][0]).T
# ~ [m1,N] = ([m_2,N]^T @ [m_2,m_1])^T
# 损失函数对 第一个隐藏层的激活前&线性输出 的偏导
if self.activeFunc == "relu":
dL_dz = dL_dz * self.__ReLU(1,forward=False) # ~ [m_1,N] * [m_1,N]
elif self.activeFunc == "sigmoid":
dL_dz = dL_dz * self.__Sigmoid(1,forward=False)
else:
print("active error in backforward")
return False

# 损失函数对第一层可训练参数偏导
self.grad[1][0] =dL_dz @ self.cache[0][0].T # 损失对w偏导
self.grad[1][1] = np.sum(dL_dz,axis=1,keepdims=True) # 损失对b偏导

'--- 结束 ---'
return True

def SGD(self,lr:int = 0.001):
'''
随机梯度下降法,固定学习率更新权重和偏置

Parameters
----------
lr : int, optional
学习率. The default is 0.001.

Returns
-------
None.

'''
for layer in range(2):
for typ in range(2):
self.param[layer+1][typ] -= lr * self.grad[layer+1][typ]

def print(self):
'''
打印网络结构信息
'''
print("---层结构:")
print(f"第一层:权重{self.param[1][0].shape},偏置{self.param[1][1].shape}")
print(f"第二层:权重{self.param[2][0].shape},偏置{self.param[2][1].shape}")
print("---梯度结构:")
print(f"第一层:权重{self.grad[1][0].shape},偏置{self.grad[1][1].shape}")
print(f"第二层:权重{self.grad[2][0].shape},偏置{self.grad[2][1].shape}")
print("---缓存量:")
print(f"输入层:{self.cache[0][0].shape}{self.cache[0][1].shape}")
print(f"第一层:权重{self.cache[1][0].shape},偏置{self.cache[1][1].shape}")
print(f"第二层:权重{self.cache[2][0].shape},偏置{self.cache[2][1].shape}")

这样,我们就可以在构建一个FC()网络对象后,通过调用3个方法FC.forward()FC.backward()FC.SGD()就可以进行网络的训练了。

网络训练

这一部分较为简单,实现为:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# 主程序
if __name__ == "__main__":
# 参数设置
EPOCH = 100
BATCHSIZE = 32
LEARNRATE = 0.001

HIDLAYERWIDTH = 128
ACTIVATEFUNC = "relu"

# 数据记录
writer = tensorboard.SummaryWriter('./log/MNIST-FC'+str(HIDLAYERWIDTH)+ACTIVATEFUNC)

# 数据集加载
transformer = torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(
(0.1307,), (0.3081,) )
])

trainDataset = torchvision.datasets.MNIST(root="./Data",
train = True,
download= True,
transform= transformer
)
trainLoader = torch.utils.data.DataLoader(trainDataset,
batch_size= BATCHSIZE, # Batch Size!
shuffle= True,
num_workers=2)

testDataset = torchvision.datasets.MNIST(root="./Data",
train = False,
download= True,
transform= transformer)

testLoader = torch.utils.data.DataLoader(testDataset,
batch_size= BATCHSIZE, # Batch Size!
shuffle= True,
num_workers=2)
testIter = iter(testLoader)

trainD = trainDataset.data.numpy()
trainD = trainD.reshape(trainD.shape[0],-1).T
trainD = trainD / 256
trainT = trainDataset.targets.numpy().T

testD = testDataset.data.numpy()
testD = testD.reshape(testD.shape[0],-1).T
testD = testD / 256
testT = testDataset.targets.numpy().T

samp = next(iter(trainLoader))
samp[0] = samp[0].numpy(); samp[1] = samp[1].numpy()
for i in range(0, 4):
plt.figure(i)
plt.imshow(samp[0][i][0])
plt.title(str(samp[1][i]))

# 创建模型
model = FC(inputS=28*28,
hidWidth=HIDLAYERWIDTH,
outputS=10,
active=ACTIVATEFUNC)

# 训练模型
Log_id = 1
for ep in range(1,EPOCH+1):
# 一次训练
print(f"-------- {ep}'th epoch begin --------")
for idx,(sampX,sampY) in enumerate(trainLoader):
# 数据维度预处理
sampX = sampX.numpy()
sampX = sampX.reshape(sampX.shape[0],-1).T
sampX = sampX / 256

sampY_vector = torch.nn.functional.one_hot(sampY,num_classes = 10).numpy().T
# 前向传播
pred = model.forward(sampX, sampY_vector)
# 反向传播
model.backward()


# SGD
model.SGD(lr = LEARNRATE)

# 数据打印
if idx %100 == 0:

# 损失计算
loss = Loss_CrossEntropy(pred,sampY_vector)
print(f"第{ep}{idx}次Batch交叉熵损失:{loss}")

# 准确度计算
testX,testY = testIter.next()
testX = testX.numpy()
testX = testX.reshape(testX.shape[0],-1).T
testX = testX / 256
if Log_id % 100 == 0:
testIter = iter(testLoader)


acc_train = model_accuracyTest(model, sampX, sampY.numpy())
acc_test = model_accuracyTest(model, testX, testY.numpy())
print(f"训练集准确度:{acc_train*100}%,测试集准确度:{acc_test*100}%")

# 记录数据
writer.add_scalar("测试集准确度", acc_test,Log_id)
writer.add_scalar("训练集准确度", acc_train,Log_id)
writer.add_scalar("交叉熵损失", loss,Log_id)
Log_id +=1

训练过程中,设计了两个用来评估网络的性能,分别是求损失函数和预测准确度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def Loss_CrossEntropy(pred:np.ndarray, real:np.ndarray):
# pred ~ [classes,N] , real ~ [classes,N]

entropy = real * np.log(pred +1e-10) # 1e-10防止无穷产生

entropy = -np.sum(entropy) / real.shape[-1]
return entropy

def model_accuracyTest(model:FC, inputD:np.ndarray, target:np.ndarray) -> float:

res = model.forward(inputD, target) # 模型推导

res = np.argmax(res,axis=0) # 选出概率最大的类型

acc = np.mean(np.equal(res,target))

在训练过程中,数据的加载和模型效果记录可视化都是借助的PyTorch,所以在文件最开头导入的(所有辅助的)库有:

1
2
3
4
5
6
7
8
import numpy as np
import torch,torchvision

import matplotlib.pyplot as plt

import copy

import torch.utils.tensorboard as tensorboard

然后把网络训练起来吧!笔者使用的环境为:

  • Anaconda 4.10.3
  • Python 3.9.7
  • Spyder 5.1.2
  • PyTorch 1.9.0
  • Numpy 1.21.2
  • CPU:i5-7300HQ(笔记本哦)

运行程序

那么接下来,看一下网络的运行效果如何吧!代码中使用了TensorBoard来记录数据,所以在训练结束后,我们可以在运行目录下运行命令:

1
$ tensorboard --logdir=./log

结果

100次epoch后的训练效果为:

  • 训练集准确度:85.36%
  • 测试集准确度:78.25%
  • 交叉熵损失:0.8257

知识总结

所以,我们总结一下在第L层全连接网络的参数量是:

$$
m_{L}*m_{L-1} + m_{L}
$$

L层的梯度信息大小和该层的(可训练)参数量是一样的:

$$
m_{L}*m_{L-1} + m_{L}
$$

在训练的过程中,第L层需要缓存的数据量为:

$$
m _ {L} * BatchSize * 2
$$

为了最小化存储,我们可以在网络反向传播时,让偏导计算每更新完一层的梯度信息就去掉不再保留,所以反向传播时,需要为内存大小动态变化的偏导数据预留空间为:

$$
m _ {max} * BatchSize
$$

当然,我们计算出来的空间,不一定就是设备需要的实际空间。比如,在进行矩阵运算的时候,中间变量还是要占用一定的空间,这是科学运算库的内存问题;以及Python对内存的占有可比C++大多了,这是编程语言的内存问题;等等。所以,最后还是要有一定的经验和对开发环境底层的了解

Donate
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • Copyrights © 2022-2023 RY.J
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信