rust手写神经网络

用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);
}

在实际操作中,可以把一个全连接层拆为两层,即将激活函数抽出单独虚拟为一层,这样更加灵活

/ob/z%E9%99%84%E4%BB%B6/rust%E6%89%8B%E5%86%99%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9C.png

先看简单的激活函数层,使用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) {
	//不需要做任何事情
}

测试:

/ob/z%E9%99%84%E4%BB%B6/rust%E6%89%8B%E5%86%99%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9C-1.png

正向传播:输入两个$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)];
	}
}

测试:

/ob/z%E9%99%84%E4%BB%B6/rust%E6%89%8B%E5%86%99%E7%A5%9E%E7%BB%8F%E7%BD%91%E7%BB%9C-2.png

 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