用rust实现上篇笔记:神经网络的结构 中描述的神经网络
还在实现中。。。
- 确定loss函数,$f(x)$为样本的推理结果,$y$是目标结果,多个样本sum得出loss
$$
loss=\frac{1}{n}\sum^n_{i=1} (f(x_i)-y_i)^2
$$
- 将训练样本分为n个batch,每个batch n个样本
- 按batch进行训练,将batch中每个样本进行正向传播
- 记录每个样本的结果,以及这个样本正向传播过程中的所有中间结果
- 遍历完每个样本后,计算loss
- 通过loss值对每个样本进行反向传播,过程中需要用到前面缓存的中间结果
- 记录每个样本反向传播过得到的梯度,将所有样本的梯度求平均值,得到最终调整网络的梯度。因为loss是sum的,所以在算一个样本偏导时,其他样本为常量,求导是0,所以每个样本梯度可以独立计算。
- 根据学习率调整网络参数
- 将所有batch如此循环来一遍,完成一轮训练
- 用所有训练样本计算下loss,多轮训练后得到满意的模型
要实现的是一个全连接神经网络,整个网络模型有很多层(Layer),除了输入层输出层,其余层都统称为隐藏层,对于要实现的网络可以叫全连接层。在每层里都有若干神经元,每个神经元上都保存着该神经元的偏置$b$和与上层神经元链接的每条边的权重$w$。 可以对Layer进行一定程度抽象,每一层都支持正向传播、反向传播、更新参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
pub struct NeuralNetworkModel {
pub layers: Vec<Box<dyn Layer>>,
}
pub trait Layer {
// 正向传播
// 返回:本层输出 & 本层中间结果
fn forward(&mut self, input: &MatView, training: bool) -> (Mat, LayerCache);
// 反向传播
// grads: 后面一层传递过来的梯度
// cache_forward: 本层正向传播时的输入和激活值,内容为forward的返回
// 返回: 本层向前一层传递的梯度 & 本层所有梯度值
fn backward(&mut self, grads: &MatView, cache_forward: &LayerCache) -> (Mat, LayerCache);
// 更新权重和偏置
// grads: 本层调整参考的梯度, 内容格式与backward返回的一致
fn update(&mut self, learning_rate: f32, grads: &LayerCache);
}
|
在实际操作中,可以把一个全连接层拆为两层,即将激活函数抽出单独虚拟为一层,这样更加灵活
先看简单的激活函数层,使用sigmod作为激活函数,激活函数层上没有任何权重和偏置,所以不需要存储任何参数:
1
2
|
// 使用激活函数sigmod的层
pub struct SigmodLayer {}
|
正向传播时只需要求每个输入的sigmod值即可,sigmod的公式是:
$$
\sigma(x) = \frac{1}{1+e^{-x}}
$$
1
2
3
4
5
6
7
8
9
10
|
// 激活函数层每个神经元只有一条入边, 只是对上层的输出做一个转换, 矩阵形状n行1列
fn forward(&mut self, input: &MatView, training: bool) -> (Mat, LayerCache) {
let out = input.map(|x| sigmod(*x));
// 只有在训练时候才保存输出值,反向传播会用到
let mut cache = vec![];
if training {
cache.push(out.clone());
}
(out, cache)
}
|
求导公式如下 详见:Sigmoid函数求导
$$
\sigma' = \sigma(x)(1-\sigma(x))
$$
有了求导公式,就可以写出sigmod层反向传播的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// 激活函数层反向传播, 对sigmod(x)求导即可, 每个神经元只有一条入边,返回的梯度是 n行1列
// simod(x)求导是 sigmod(x)*(1-sigmod(x))
// 每个神经元有多条出边,链式法则后要累加结果
fn backward(&mut self, grads: &MatView, cache_forward: &LayerCache) -> (Mat, LayerCache) {
// sigmod(x)的值
let a = cache_forward[0].view();
// 激活函数层的输入和输出数量是相等的, 返回值长度和前一层神经元数量一致
let mut r = Mat::from_shape_fn((a.len(), 1), |(_, _)| 0.);
// 对每个神经元求梯度
for (i, out) in a.iter().enumerate() {
// 累加当前神经元每条出边的偏导
for g in grads.rows().into_iter() {
// 链式法则,与输入偏导相乘
// 当前神经元为 i, 所以g也取每行第i个
r[(i, 0)] += g[i] * (out * (1.0 - out));
}
}
// 激活函数层没有任何存储任何权重和偏置,无需update
(r, vec![])
}
|
没有参数,所以不需要根据梯度更新参数:
1
2
3
|
fn update(&mut self, _learning_rate: f32, _gradss: &LayerCache) {
//不需要做任何事情
}
|
测试:
正向传播:输入两个$0$,sigmod层激活值是$0.5$,输出层值为$1$
反向传播:$z=wa+b$对$w$求偏导为$a$,所以输出层每个边的偏导为$0.5$
sigmod层的偏导为 $\sum \sigma \times (1-\sigma) \times g = 0.5 \times (1-0.5) \times 0.5 = 0.125$,每个神经元只有一条出边所以不用sum
1
2
3
4
5
6
7
|
let mut s = SigmodLayer::new();
let a = s.forward(&array![[0.], [0.]].view(), true);
println!("a:\n{}", a);
assert_eq!(a, array![[0.5], [0.5]]);
let g = s.backward_and_update(&array![[0.5, 0.5]].view(), 0.1);
println!("g:\n{}", g);
assert_eq!(g, array![[0.125], [0.125]])
|
完整代码 4157fcb
全连接层需要存储w和b,并且特地不带激活函数:
1
2
3
4
5
6
7
|
// 没有激活函数的全连接层
pub struct DenseLayerNoActive {
// 每个神经元与上一层所有神经元边的权重, n行j列,n是本层神经元个数,j是前一层神经元个数
pub w: Mat,
// 每个神经元的偏置, n行1列
pub b: Mat,
}
|
正向传播公式很简单,单个节点的激活值:
$$
z = w_1\times a_1 + w_2\times a_2 +\ … + w_n*a_n + b
$$
1
2
3
4
5
6
7
8
9
10
|
fn forward(&mut self, input: &MatView, training: bool) -> (Mat, LayerCache) {
// 计算每个神经元激活值 w1*a1 + w2*a2 + ... + wn*an + b
// 矩阵计算,一次算出结果, w的每行乘以输入的一列最后加b
let r = self.w.dot(input) + &self.b;
let mut cache = vec![];
if training {
cache.push(input.to_owned());
}
(r, cache)
}
|
对 $w_i$ 求偏导为$a_i$ 对$b$求偏导为$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
|
// 每个神经元有多(k)条入边返回的梯度是 n行k列
// z=w*a+b 对w求导是a, 对b求导是1
// 每个神经元看作有多条出边,链式法则后仍要累加(大多情况后一层是激活函数层,只有1条出边,但不排除其他可能)
fn backward(&mut self, grads: &MatView, cache_forward: &LayerCache) -> (Mat, LayerCache) {
let a = cache_forward[0].view();
let mut bias_grads = Mat::zeros(self.b.raw_dim());
let mut w_grads = Mat::zeros(self.w.raw_dim());
// 对每个神经元求所有w和b的偏导, 每个w的导数都是与其相乘的a, w不需要参与, 对b的偏导是1
for (i, _) in self.w.columns().into_iter().enumerate() {
// 累加当前神经元每条出边上的偏导, grads的每行,都是前一层某个神经元和本层连线的偏导
for g in grads.rows().into_iter() {
// b在这里求 链式法则相乘
bias_grads[(i, 0)] += g[i] * 1.;
//每个神经元上都有和前一层神经元的边, 连接w和a
for (k, a) in a.rows().into_iter().enumerate() {
w_grads[(i, k)] += a[0] * g[i];
}
}
}
let grads_cache = vec![bias_grads, w_grads.clone()];
// 入边只和w有关系,不用返回偏置上的偏导
(w_grads, grads_cache)
}
|
最后实现参数更新:
1
2
3
4
5
6
7
8
9
10
11
12
|
fn update(&mut self, learning_rate: f32, grades: &LayerCache) {
let bias_grads = grades[0].view();
let w_grads = grades[1].view();
// 更新偏置
let (i, j) = (self.w.shape()[0], self.w.shape()[1]);
for i in 0..i {
for j in 0..j {
self.w[(i, j)] -= learning_rate * w_grads[(i, j)];
}
self.b[(i, 0)] -= learning_rate * bias_grads[(i, 0)];
}
}
|
测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
let mut d = DenseLayerNoActive {
w: array![[2., 2.], [2., 2.]],
b: array![[0.1], [0.1]],
};
let (a, f_cache) = d.forward(&array![[0.5], [1.]].view(), true);
println!("a:\n{}", a);
assert_eq!(a, array![[3.1], [3.1]]);
let (g, b_cache) = d.backward(&array![[3.1, 3.1], [3.1, 3.1]].view(), &f_cache);
println!(
"g:\n{}, b_cache:\ng_b:\n{}\ng_w\n{}",
g, b_cache[0], b_cache[1]
);
assert_eq!(g, array![[3.1, 6.2], [3.1, 6.2]]);
d.update(0.1, &b_cache);
println!("d.w:\n{}\nd.b\n{}", d.w, d.b);
|
完整代码 4157fcb