反向传播 - Back Propagation

是前几天写 DNN 的时候遇到了一个问题,后来拿 torch 对拍,才找到的,这边也是记录一下

代码实现在这边: DNN

原理

反向传播是很自然的想法,在链式求导法中,就是由外向内递归到单变量的,因此偏导数其实是一整条路径上每一个函数对函数值变化的贡献

这是两个函数复合的情况,而 DNN 里其实也就是一系列函数的复合,例如对于这个(2+2)层的神经网络

DNN

其中

损失函数使用 MSE

那么当我们在反向传播时,例如对于 来说

对于 的某一个权重 来说

类似的,对于 我们有

我们可以观察到,前面的部分是一致的,重复计算的部分,同时,这些偏导数都是可以在节点上依次从后往前算出最后得到的,因此可以将后面的偏导数传到前面的节点,而它的偏导数是关于它的输入的,因此在前向传播时,我们就能计算其偏导数。

梯度下降

想像一个凹函数(损失函数),比如

训练的过程,就是我们寻找损失函数最低点(极小值)的过程,那么我们可以想见,如果我们在极小值的一侧,我们可以往梯度的反方向走,最终就能到达极小值,这就是梯度下降的想法。

但是实际上,我们并不能知道要沿这个方向走多远,因此我们每次都选择走一小步,然后检查损失函数的变化,再次计算梯度。步长则是根据学习率确定。

对于一个权重 ,其中 为学习率

我遇到的问题

这个问题来源于我训练 XOR 的时候模型不收敛,理论上来说,虽然 sigmoid 的有效梯度区间比较小,但是你 epoch 数够了总是能训练出来的,后来我就一通验证,一开始我考虑是不是梯度算错了,我搁那手算梯度(还好层数少),发现是没有问题的,就找 LLM 写了个 torch 的板子,模型建的和我自己的一样。

我就去对拍,把 torch 的 weights 初始化和我的模型一致,再跑一个 epoch 对一遍参数更新情况,就遇到一个比较抽象的问题,就是如果每次都输入只有一个样本,那么我的参数更新和 torch 一致,而如果输入不止一个样本,参数更新就有问题了,我就去看我的梯度下降部分,这才发现问题。

其实这个项目是从一个计算图上面改过来的,这个计算图当时设计时,是考虑对每一组的输入、输出求偏导,而 MSE 是计算多组输入平均的损失,因此,对于每一个权重 ,其参数更新要用多组参数的梯度的平均,因此需要每一份梯度的累加。

这里也就解决了我之前的,一个困惑,也就是 torch 训练时,都要 optimizer.zero_grad(),就是把累积的梯度清零,其实这个梯度就是这边累积的,批量梯度下降就是一个 epoch 清一次,而 SGD 就是一个样本一次 step,然后 zero_grad

也就把 training 部分改成了这样

1
2
3
4
5
6
7
8
9
10
11
12
13
void DNN::training(ld lr, size_t epoch, vector<vector<ld>> &inputs, vector<vector<ld>> &labels) {
for (size_t i = 0; i < epoch; i++) {
clearGrad();
for (size_t t = 0; t < inputs.size(); t++) {
// clearGrad();
vector<ld> res = eval(inputs[t]);
vector<ld> lossGrad(MSELoss(labels[t], res, inputs.size()));
bp(lossGrad);
// step(lr);
}
step(lr);
}
}

在跑每组样本时累积梯度,在跑完样本之后 step 进行参数更新