1 反向传播算法
1.1 反向传播算法的公式
反向传播算法包含四个方程,公式的推导这里就不给大家推了(网上有一大堆现成的推导博客),其实核心就是误差的定义以及求导的链式法则,下面我只列出反向传播的四个方程:
δ
L
=
∇
a
C
⊙
σ
′
(
z
L
)
δ
l
=
(
(
ω
l
+
1
)
T
δ
l
+
1
)
⊙
σ
′
(
z
l
)
∂
C
∂
b
j
l
=
δ
j
l
∂
C
∂
ω
j
k
l
=
a
j
l
−
1
δ
k
l
\begin{aligned} \delta^{L} &= \nabla_{a}C \odot \sigma'(z^{L}) \\ \delta^{l} &= ((\omega^{l+1})^{T} \delta^{l+1}) \odot \sigma'(z^{l}) \\ \frac {\partial C} {\partial b^{l}_{j}} &= \delta^{l}_{j} \\ \frac{\partial C}{\partial \omega^{l}_{jk}} &= a^{l-1}_{j} \delta^{l}_{k} \end{aligned}
δLδl∂bjl∂C∂ωjkl∂C=∇aC⊙σ′(zL)=((ωl+1)Tδl+1)⊙σ′(zl)=δjl=ajl−1δkl
式中,
C
C
C代表损失函数,
δ
L
\delta^{L}
δL代表输出层的误差,
δ
l
\delta^{l}
δl代表输出层的误差,
ω
\omega
ω代表权重,
b
b
b代表偏置,
z
z
z代表未激活前的值(上一层的输出乘上相应的权重求和后的值),
a
a
a代表激活后的值,
b
j
l
b^{l}_{j}
bjl代表第
l
l
l层中第
j
j
j个节点所对应的偏置,
δ
j
l
\delta^{l}_{j}
δjl代表第
l
l
l层中第
j
j
j个节点所对应的误差,
ω
j
k
l
\omega^{l}_{jk}
ωjkl代表第
l
−
1
l-1
l−1层中第
j
j
j个节点与第
l
l
l层中第
k
k
k个节点之间的权重,
a
j
l
−
1
a^{l-1}_{j}
ajl−1代表第
l
−
1
l-1
l−1层中第
j
j
j个节点所对应的输出值。前两个公式是矩阵运算,后两个公式是具体到每一个值的计算。下面介绍反向传播算法的流程。
1.2 反向传播算法的流程
通过公式可知,在进行求解误差之前,我们需要得到 z z z和 a a a,所以需要先进行前向的计算,然后再利用公式对误差进行反向传播,如下:
反向传播算法
step1: 前向计算,得到 z l z^{l} zl和 a l a^{l} al
step2: 计算输出层误差: δ L = ∇ a C ⊙ σ ′ ( z L ) \delta^{L} = \nabla_{a}C \odot \sigma'(z^{L}) δL=∇aC⊙σ′(zL)
step3: 计算隐藏层误差: δ l = ( ( ω l + 1 ) T δ l + 1 ) ⊙ σ ′ ( z l ) \delta^{l} = ((\omega^{l+1})^{T} \delta^{l+1}) \odot \sigma'(z^{l}) δl=((ωl+1)Tδl+1)⊙σ′(zl)
step4: 计算参数的梯度:
∂ C ∂ b j l = δ j l ∂ C ∂ ω j k l = a j l − 1 δ k l \begin{aligned} \frac {\partial C} {\partial b^{l}_{j}} &= \delta^{l}_{j} \\ \frac{\partial C}{\partial \omega^{l}_{jk}} &= a^{l-1}_{j} \delta^{l}_{k} \end{aligned} ∂bjl∂C∂ωjkl∂C=δjl=ajl−1δkl
反向传播算法执行一次之后,我们就得到了所有层的权重与偏置的梯度,从而可以进行参数更新,训练我们的模型,下面是在一轮中(一个周期中),模型训练的流程:
模型一轮训练的流程
step1: 分割训练集为 m m m份训练数据,令 i = 0 i=0 i=0
step2: 取第 i i i份训练数据作为模型的输入
step3: 通过反向传播算法得到梯度
step4: 使用某种更新方法对参数进行更新
step5: 令 i = i + 1 i=i+1 i=i+1,若 i < m i<m i<m,回到step2;否则结束一轮训练
下面根据上述的方程与流程进行反向传播算法的实现。
2 反向传播算法的代码实现
为了方便展示,我省略了一些代码实现的细节,如果需要完整代码,可以访问我的仓库。如果觉得代码对您有帮助的话,可以给我的仓库star一下。
2.1 网络层的定义
首先创建一个数据结构,用于定义每一层的相关参数:
struct Layer {
vector<vector<double>> weights; // 权重(上一层连到当前层)
vector<double> bias; // 偏置
vector<double> input; // 上一层的输出乘上对应权重求和后的结果(未经过激活函数)
vector<double> out; // 经过激活函数后当前层的输出
vector<double> error; // 当前层的误差
vector<vector<double>> weights_grad; // 权重的梯度
vector<double> bias_grad; // 偏置的梯度
};
2.2 模型的定义
将上面定义的网络层集合到一起,形成模型,同时使用一些函数使得能够清晰地访问我们需要的参数
(这里请不用纠结为什么是layer-1,这和构造函数中对模型的初始化有关,为了尽可能只体现反向传播算法的相关内容,这里舍去构造函数的定义,详见完整代码):
class Network {
private:
int layers_num; // 隐藏层层数+1(隐藏层+输出层)
vector<Layer> layers; // 存储隐藏层和输出层
vector<int> nodes_num_per_layer; // 网络每一层的节点数,包括输入层
vector<double> data; // 训练数据(在构造函数中构建)
vector<double> label; // 训练标签(在构造函数中构建)
int data_size = 1000; // 训练集的大小
double lr; // 学习率
int epochs; // 训练周期
public:
inline double get_weight(int layer, int i, int j) { // 第layer层中,上一层第i个节点连接到当前层第j个节点的权重值
return layers[layer - 1].weights[i][j];
}
inline double get_bias(int layer, int j) { // 第layer层中,第j个节点的偏置
return layers[layer - 1].bias[j];
}
inline double get_input(int layer, int j) { // 第layer层中,第j个节点的输入
return layers[layer - 1].input[j];
}
inline double get_output(int layer, int j) { // 第layer层中,第j个节点的输出
return layers[layer - 1].out[j];
}
inline double get_error(int layer, int j) { // 第layer层中,第j个节点的误差
return layers[layer - 1].error[j];
}
inline double get_weight_grad(int layer, int i, int j) { // 第layer层中,上一层第i个节点连接到当前层第j个节点的权重的梯度
return layers[layer - 1].weights_grad[i][j];
}
inline double get_bias_grad(int layer, int j) { // 第layer层中,第j个节点的偏置的梯度
return layers[layer - 1].bias_grad[j];
}
...
...
...
};
2.3 前向计算
由上述可知,反向传播之前需要先进行前向的计算过程,所以这里定义前向的计算过程(注意,下述代码所给的计算过程是针对于单样本而言的):
class Network {
...
...
...
void forward(int index) {
// 从输入层到第一层
for (int i = 0; i < nodes_num_per_layer[1]; i++) {
layers[0].input[i] = data[index] * get_weight(1, 0, i) + get_bias(1, i);
layers[0].out[i] = activate(get_input(1, i), active_type); // activate是激活函数
}
// 各隐藏层和输出层
for (int n = 2; n <= layers_num; n++) {
for (int i = 0; i < nodes_num_per_layer[n]; i++) { // 当前层
layers[n - 1].input[i] = 0;
// 对上一层的结果加权求和
for (int j = 0; j < nodes_num_per_layer[n - 1]; j++) { // 上一层
layers[n - 1].input[i] += get_output(n - 1, j) * get_weight(n, j, i);
}
layers[n - 1].input[i] += get_bias(n, i);
// 将z输入激活函数
layers[n - 1].out[i] = activate(get_input(n, i), active_type);
}
}
}
};
2.4 反向传播
计算完前向过程之后就可以进行反向传播,先利用第一个方程计算输出层的误差,再利用第二个方程将误差从输出层反向传播回每一层,同样是针对单个样本的反向传播:
class Network {
...
...
...
void backward(int index) {
// 计算输出层的误差
// activate_det为激活函数的导数
layers[layers_num - 1].error[0] = (get_output(layers_num, 0) - label[index]) * activate_det(get_input(layers_num, 0), active_type); // 第一个方程
// 计算隐藏层的误差
for (int n = layers_num - 1; n >= 1; n--) {
for (int i = 0; i < nodes_num_per_layer[n]; i++) { // 当前层
layers[n - 1].error[i] = 0;
for (int j = 0; j < nodes_num_per_layer[n + 1]; j++) { // 下一层
layers[n - 1].error[i] += get_weight(n + 1, i, j) * get_error(n + 1, j) * activate_det(get_input(n, i), active_type);
}
}
}
}
};
2.5 更新参数
这里使用随机梯度下降对参数进行更新:
(完整代码中还包含一些其他优化器的更新方式)
class Network {
...
...
...
void update_sgd(int index) {
// 更新第一层的权重
for (int j = 0; j < nodes_num_per_layer[1]; j++) {
layers[0].weights[0][j] -= lr * get_error(1, j) * data[index];
}
// 更新隐藏层的权重
for (int n = 2; n <= layers_num; n++) {
for (int j = 0; j < nodes_num_per_layer[n]; j++) { // 当前层
for (int i = 0; i < nodes_num_per_layer[n - 1]; i++) { // 上一层
layers[n - 1].weights[i][j] -= lr * get_error(n, j) * get_output(n - 1, i);
}
}
}
// 更新所有层的偏置
for(int n = 1; n <= layers_num; n++) {
for (int i = 0; i < nodes_num_per_layer[n]; i++) {
layers[n - 1].bias[i] -= lr * get_error(n, i);
}
}
}
};
2.6 训练
最后如1.2节中所展示的,进行完整的训练流程:
class Network {
...
...
...
void train() {
for (int epoch = 0; epoch < epochs; epoch++) {
cout << epoch << endl;
// 单样本进行训练
for (int index = 0; index < data.size(); index++) {
forward(index);
backward(index);
update_sgd(index);
}
}
}
};
至此,反向传播算法与训练流程已经实现完毕。
如果有任何问题,欢迎在评论区批评指正,不胜感激!!!