用 Rust 从零构建神经网络
手把手讲解神经网络数学原理,用 Rust 从零实现完整系统,适合想深入理解深度学习的开发者。
手把手讲解神经网络数学原理,用 Rust 从零实现完整系统,适合想深入理解深度学习的开发者。
让我们从零开始构建一个神经网络,真正理解它的工作原理。所谓“从零开始”,是指不使用任何复杂的 ML 或线性代数库。我们将构建一个单层感知机——最简单的神经网络,然后教会它对两个数字执行加法运算。
本文将使用 Rust,不过如果你更喜欢 JavaScript 或 Python,也可以参照相应实现一起操作。代码可以在这里获取。
神经网络正如其名:由人工神经元组成的网络。神经元,也叫“节点”,是一种模拟生物神经元功能的计算单元。它接收一些输入,对其执行数学运算,然后输出结果。
首先,我们定义一个 NeuralNetwork struct,并为它添加一个用于初始化的关联方法。目前还没有太多内容可写,暂时先让它保持为空。
struct NeuralNetwork {}
impl NeuralNetwork {
fn new() -> Self {
Self {}
}
}
神经网络中的神经元会被组织成不同的层。每一层都以某种方式处理输入,并将自己的输出作为下一层的输入。第一层接收初始输入,最后一层生成最终输出。两者之间的层称为隐藏层,负责从数据中提取和转换特征。
每个圆圈代表一个神经元,每条线代表一条连接。可以看到,一层中的每个神经元都与下一层的所有神经元相连。
我们要构建的神经网络是这样的:它有两个输入神经元,每个数字对应一个;还有一个输出神经元。注意,它没有任何隐藏层。
神经网络中的每条连接都有一个对应的权重。权重决定了神经元之间关系的强度和方向,也就是产生正面还是负面影响,进而影响数据从一层传递到下一层时如何被转换。在训练过程中,我们会调整这些权重值,以减小输出中的误差。
理解权重的一种好方法,是把神经连接想象成在神经元之间传递信号,也就是数据的管道。这些管道的粗细可以改变。管道越粗,代表连接越强,对从中经过的信号影响也就越大。
由于我们的网络没有隐藏层,因此只需要两个权重,每条连接对应一个。现在先将它们初始化为随机浮点数。
struct NeuralNetwork {
weights: Vec<f64>
}
impl NeuralNetwork {
fn new() -> Self {
let mut rng = rand::thread_rng();
let weights = vec![rng.gen_range(0.0..1.0), rng.gen_range(0.0..1.0)];
Self {
weights
}
}
}
偏置是另一个用于调整神经元输出的参数。可以把它想象成每个神经元旁边的一个小旋钮。无论神经元接收到什么信号,这个旋钮都能调整它的活跃程度。它就像是在微调神经元的基础活跃水平,确保即使所有传入信号都很弱,神经元仍然可以根据偏置的调整决定是否触发,也就是激活。
只有隐藏层和输出层中的神经元才有偏置。由于我们的感知机没有隐藏层,输出层也只有一个神经元,所以只需要一个偏置值,同样将它初始化为随机值。
struct NeuralNetwork {
// [...]
bias: f64
}
impl NeuralNetwork {
fn new() -> Self {
// [...]
Self {
// [...]
bias: rng.gen_range(0.0..1.0)
}
}
}
激活函数负责决定神经元是否应该触发。它有点像过滤器或守门员:查看经过权重和偏置调整后的传入信号,再决定神经元应该以多大的程度被激活。激活函数用于为网络引入非线性,而这种非线性让网络能够学习复杂的模式。
神经元的输出可以按如下方式计算:
我们将输入与传出连接的权重相乘,加上偏置,然后对结果应用激活函数。
impl NeuralNetwork {
// [...]
fn predict(&self, input: &[f64; 2]) -> f64 {
let mut sum = self.bias;
for (i, weight) in self.weights.iter().enumerate() {
sum += input[i] * weight;
}
sigmoid(sum)
}
}
Sigmoid 是一种相对简单的非线性激活函数。它所做的,就是以非线性的方式将参数转换为 0 到 1 之间的值。可以尝试用不同的值调用它,看看它是如何工作的。
fn sigmoid(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
这是训练神经网络的核心算法。如果最终输出不符合预期,我们就会将误差从网络的各层“反向传播”,找出哪些路径,也就是哪些连接,分别对最终输出的整体误差贡献了多少。然后利用这些信息,对网络中的权重和偏置进行小幅调整,从而减小未来输出中的误差。我们会一遍又一遍地重复这个过程,逐步优化网络参数,直到它的输出足够接近预期结果。
impl NeuralNetwork {
// [...]
fn train(&mut self, inputs: Vec<[f64; 2]>, outputs: Vec<f64>, epochs: usize) {
for _ in 0..epochs {
for (i, input) in inputs.iter().enumerate() {
// Get a prediction for a given input
let output = self.predict(input);
// Compute the difference between the actual and the desired output
let error = outputs[i] - output;
// Find the gradient of the loss function
// (sort of like a hint about the direction to adjust the weights in)
let delta = derivative(output);
// Adjust the weights and the bias to reduce error in the output
for j in 0..self.weights.len() {
self.weights[j] += self.learning_rate * error * input[j] * delta;
}
self.bias += self.learning_rate * error * delta;
}
}
}
}
fn derivative(x: f64) -> f64 {
x * (1.0 - x)
}
一个 epoch,指的是对整个训练数据集进行一次完整遍历。在每个 epoch 中,我们会将训练数据里的每组输入送入网络,把网络输出与正确结果进行比较,再利用两者之间的差异重新调整网络参数。通过重复多个 epoch,可以不断改善模型的表现。
最后一步,是为网络添加学习率。学习率是一个常量,用于决定模型从错误中学习的速度。我们将它设为 0.1。
struct NeuralNetwork {
// [...]
learning_rate: f64,
}
impl NeuralNetwork {
fn new() -> Self {
// [...]
Self {
// [...]
learning_rate: 0.1,
}
}
现在神经网络已经准备好了,让我们实际使用它。
fn main() {
let d = data::get_data().unwrap();
let inputs = d.training_inputs;
let outputs = d.training_outputs;
let test_inputs = d.test_inputs;
// Initialize the network
let mut neural_net = NeuralNetwork::new();
for input in test_inputs.iter() {
// Pass a set of inputs (two numbers) and get a prediction back which should be a sum of the two numbers
let prediction = neural_net.predict(input);
println!("Input: {:?}, Prediction: {:.1}", input, prediction);
}
}
运行后,会得到类似下面这样的输出。
Input: [0.9, 0.1], Prediction: 0.7
Input: [0.5, 0.5], Prediction: 0.7
Input: [0.2, 0.3], Prediction: 0.7
Input: [0.3, 0.6], Prediction: 0.7
Input: [0.1, 0.7], Prediction: 0.7
Input: [0.3, 0.1], Prediction: 0.7
Input: [0.1, 0.5], Prediction: 0.7
Input: [0.9, 0.0], Prediction: 0.7
Input: [0.3, 0.3], Prediction: 0.7
Input: [0.0, 0.1], Prediction: 0.6
Input: [0.1, 0.2], Prediction: 0.7
Input: [0.2, 0.0], Prediction: 0.6
Input: [0.6, 0.1], Prediction: 0.7
Input: [0.5, 0.3], Prediction: 0.7
Input: [0.9, 0.1], Prediction: 0.7
Input: [0.1, 0.4], Prediction: 0.7
Input: [0.2, 0.4], Prediction: 0.7
Input: [0.7, 0.0], Prediction: 0.7
Input: [0.6, 0.3], Prediction: 0.7
Input: [0.2, 0.2], Prediction: 0.7
Input: [0.1, 0.0], Prediction: 0.6
Input: [0.2, 0.6], Prediction: 0.7
Input: [0.5, 0.0], Prediction: 0.7
Input: [0.6, 0.4], Prediction: 0.7
Input: [0.4, 0.5], Prediction: 0.7
可以看到,我们的网络完全不擅长数字加法。在 25 次预测中,它只猜对了 2 次,而且纯属碰巧。这是因为我们还没有训练它。现在加入训练步骤。
let mut neural_net = NeuralNetwork::new();
// Train for 10000 epochs
neural_net.train(inputs, outputs, 10000);
Input: [0.9, 0.1], Prediction: 0.9
Input: [0.5, 0.5], Prediction: 0.9
Input: [0.2, 0.3], Prediction: 0.5
Input: [0.3, 0.6], Prediction: 0.9
Input: [0.1, 0.7], Prediction: 0.8
Input: [0.3, 0.1], Prediction: 0.4
Input: [0.1, 0.5], Prediction: 0.6
Input: [0.9, 0.0], Prediction: 0.9
Input: [0.3, 0.3], Prediction: 0.6
Input: [0.0, 0.1], Prediction: 0.1
Input: [0.1, 0.2], Prediction: 0.3
Input: [0.2, 0.0], Prediction: 0.2
Input: [0.6, 0.1], Prediction: 0.7
Input: [0.5, 0.3], Prediction: 0.8
Input: [0.9, 0.1], Prediction: 0.9
Input: [0.1, 0.4], Prediction: 0.5
Input: [0.2, 0.4], Prediction: 0.6
Input: [0.7, 0.0], Prediction: 0.7
Input: [0.6, 0.3], Prediction: 0.9
Input: [0.2, 0.2], Prediction: 0.4
Input: [0.1, 0.0], Prediction: 0.1
Input: [0.2, 0.6], Prediction: 0.8
Input: [0.5, 0.0], Prediction: 0.5
Input: [0.6, 0.4], Prediction: 0.9
Input: [0.4, 0.5], Prediction: 0.9
哇!现在它大约有 80% 的时候能正确猜出答案。还不错。
我们的模型之所以有效,是因为要解决的问题极其简单。更大的神经网络能够“记住”更加复杂的模式,它们可能包含分布在众多层中的数十万个神经元,并采用卷积神经网络或循环神经网络等复杂架构。例如,据称 GPT-3 拥有 1750 亿个参数,而它的后继者 GPT-4 据传拥有 1.76 万亿个参数。相比之下,我们的模型只有区区 3 个参数:2 个权重加 1 个偏置。
就是这样,我们用 Rust 从零构建了一个神经网络。
部分评论可能仅对已登录的访问者可见。登录后即可查看所有评论。
如果需要采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。