Rust 也能完结神经网络?
作者 | Nathan J. Goldbaum
译者 | 弯月,责编 | 屠敏
出品 | CSDN(ID:CSDNnews)
以下为译文:
我在前一篇帖子(http://neuralnetworksanddeeplearning.com/chap1.html)中介绍了MNIST数据集(http://yann.lecun.com/exdb/mnist/)以及分辩手写数字的问题。在这篇文章中,我将运用前一篇帖子中的代码,通过Rust完结一个简略的神经网络。我的方针是探究用Rust完结数据科学作业流程的功能以及人工效率。
Python的完结
我在前一篇帖子中描绘了一个十分简略的单层神经网络,其可以运用依据随机梯度下降的学习算法对MNIST数据会集的手写数字进行分类。听起来有点杂乱,但实际上只要150行Python代码,以及很多注释。
假如你想深化了解神经网络的基础知识,请细心阅读我的前一篇帖子。并且请不要只重视代码,了解代码作业原理的细节并不是十分重要,你需求了解Python和Rust的完结差异。
在前一篇帖子中,Python代码的根本数据容器是一个Network类,它表明一个神经网络,其层数和每层神经元数可以自在操控。在内部,Network类由NumPy二维数组的列表表明。该网络的每一层都由一个表明权重的二维数组和一个表明误差的一维数组组成,别离包括在Network类的特点weights和biases中。两者都是二维数组的列表。误差是列向量,但仍然添加了一个无用的维度,以二维数组的办法存储。Network类的初始化程序如下所示:
classNetwork(object):
def__init__(self, sizes):
"""The list ``sizes`` contains the number of neurons in the
respective layers of the network. For example, if the list
was [2, 3, 1] then it would be a three-layer network, with the
first layer containing 2 neurons, the second layer 3 neurons,
and the third layer 1 neuron. The biases and weights for the
network are initialized randomly, using a Gaussian
distribution with mean 0, and variance 1. Note that the first
layer is assumed to be an input layer, and by convention we
won't set any biases for those neurons, since biases are only
ever used in computing the outputs from later layers."""
self.num_layers = len(sizes)
self.sizes = sizes
self.biases = [np.random.randn(y,1)foryinsizes[1:]]
self.weights = [np.random.randn(y, x)
forx, yinzip(sizes[:-1], sizes[1:])]
在这个简略的完结中,权重和误差的初始化呈规范正态散布——即均值为零,规范差为1的正态散布。咱们可以看到,误差明确地初始化为列向量。
这个Network类公开了两个用户可以直接调用的办法。第一个是evaluate办法,它要求网络测验辨认一组测验图画中的数字,然后依据已知的正确答案对成果进行评分。第二个是SGD办法,它通过迭代一组图画来运转随机梯度下降的学习进程,将整组图画分解成小批次,然后依据每一小批次的图画以及用户指定的学习速率eta更新该网络的状况;终究再依据用户指定的迭代次数,随机挑选一组小批次图画,从头运转这个操练进程。该算法的中心(每一小批次图画处理以及神经网络的状况更新)代码如下所示:
defupdate_mini_batch(self, mini_batch, eta):
"""Update the network's weights and biases by applying
gradient descent using backpropagation to a single mini batch.
The ``mini_batch`` is a list of tuples ``(x, y)``, and ``eta``
is the learning rate."""
nabla_b = [np.zeros(b.shape)forbinself.biases]
nabla_w = [np.zeros(w.shape)forwinself.weights]
forx, yinmini_batch:
delta_nabla_b, delta_nabla_w =self.backprop(x, y)
nabla_b = [nb+dnbfornb, dnbinzip(nabla_b, delta_nabla_b)]
nabla_w = [nw+dnwfornw, dnwinzip(nabla_w, delta_nabla_w)]
self.weights = [w-(eta/len(mini_batch))*nw
forw, nwinzip(self.weights, nabla_w)]
self.biases = [b-(eta/len(mini_batch))*nb
forb, nbinzip(self.biases, nabla_b)]
咱们可以针对小批次中的每个操练图画,通过反向传达(在backprop函数中完结)求出价值函数的梯度的估量值的总和。在处理彻底部的小批次后,咱们可以依据估量的梯度调整权重和误差。更新时在分母中加入了len(mini_batch),因为咱们想要小批次中全部估量的均匀梯度。咱们还可以通过调整学习速率eta来操控权重和误差的更新速度,eta可以在大局范围内调整每个小批次更新的巨细。
backprop函数在核算该神经网络的价值函数的梯度时,首要从输入图画的正确输出开端,然后将过错反向传达至网络的各层。这需求很多的数据调整,在将代码移植到Rust时我在此花费了很多的时刻,在此篇幅有限,我无法深化解说,假如你想了解具体的概况,请参照这本书(http://neuralnetworksanddeeplearning.com/chap2.html)。
Rust的完结
首要,咱们需求弄清楚怎么加载数据。这个进程十分繁琐,所以我另写了一篇文章专门评论(https://ngoldbaum.github.io/posts/loading-mnist-data-in-rust/)。在这之后,下一步咱们有必要弄清楚怎么用Rust表明Python代码中的Network类。终究我决议运用struct:
usendarray::Array2;
#[derive(Debug)]
structNetwork{
num_layers: usize,
sizes: Vec,
biases: Vec>,
weights: Vec>,
}
该结构的初始化与Python的完结大致相同:依据每层中的神经元数量进行初始化。
userand::distributions::StandardNormal;
usendarray::{Array,Array2};
usendarray_rand::RandomExt;
impl Network {
fnnew(sizes: &[usize]) -> Network {
let num_layers = sizes.len();
let mut biases: Vec> = Vec::new();
let mut weights: Vec> = Vec::new();
fori in1..num_layers {
biases.push(Array::random((sizes[i],1), StandardNormal));
weights.push(Array::random((sizes[i], sizes[i -1]), StandardNormal));
}
Network {
num_layers: num_layers,
sizes: sizes.to_owned(),
biases: biases,
weights: weights,
}
}
}
整体式包的长处
原则上,最好不要将随机数生成器放到ndarray代码库中,这样当rand函数支撑新的随机散布时,ndarray以及Rust生态体系中全部需求随机数的包都会获益。另一方面,这的确会添加一些认知开支,因为没有会集的方位,查阅文档时需求参阅多个包的文档。我的状况有点特别,我没想到做这个项目的时分,恰逢rand发布改变了其公共API的版别。导致ndarray-rand(依靠于rand版别0.6)和我的项目所依靠的版别0.7之间产生了不兼容性。
我传闻cargo和Rust的构建体系可以很好地处理这类问题,但至少我遇到了一个十分令人困惑的过错信息:我传入的随机数散布不能满意Distribution这个trait的要求。尽管这话不假——它契合0.7版别的rand,但不契合ndarray-rand要求的0.6版别的rand,但这仍然十分令人费解,因为过错信息中没有给出各种包的版别号。终究我报告了这个问题。我发现这些有关API版别不兼容的过错音讯是Rust言语长期存在的一个问题。期望将来Rust可以显现更多有用的过错信息。
终究,这种重视点的别离给我这个新用户带来了很大困难。在Python中,我可以简略通过import numpy完结。我的确以为NumPy在整体式上走得太远了(其时打包和分发带有C扩展的Python代码与现在比较太难了),但我也以为在另一个极端上渐行渐远,会导致言语或生态体系的学习难度增大。
类型和全部权
下面我将具体介绍一下Rust版别的update_mini_batch:
impl Network {
fnupdate_mini_batch(
&mut self,
training_data: &[MnistImage],
mini_batch_indices: &[usize],
eta: f64,
){
letmut nabla_b: Vec> = zero_vec_like(&self.biases);
letmut nabla_w: Vec> = zero_vec_like(&self.weights);
foriinmini_batch_indices {
let(delta_nabla_b, delta_nabla_w) = self.backprop(&training_data[*i]);
for(nb, dnb)innabla_b.iter_mut().zip(delta_nabla_b.iter()) {
*nb += dnb;
}
for(nw, dnw)innabla_w.iter_mut().zip(delta_nabla_w.iter()) {
*nw += dnw;
}
}
letnbatch = mini_batch_indices.len()asf64;
for(w, nw)inself.weights.iter_mut().zip(nabla_w.iter()) {
*w -= &nw.mapv(|x| x * eta / nbatch);
}
for(b, nb)inself.biases.iter_mut().zip(nabla_b.iter()) {
*b -= &nb.mapv(|x| x * eta / nbatch);
}
}
}
该函数运用了我界说的两个辅佐函数,因而更为简练:
fn to_tuple(inp: &[usize]) -> (usize, usize) {
match inp {
[a, b] => (*a, *b),
_ => panic!(),
}
}
fn zero_vec_like(inp: &[Array2]) -> Vec> {
inp.iter()
.map(|x| Array2::zeros(to_tuple(x.shape())))
.collect()
}
与Python完结比较,调用update_mini_batch的接口有点不同。这儿,咱们没有直接传递目标列表,而是传递了整套操练数据的引证以及数据会集的索引的切片。因为这种做法不会触发借用查看,因而更简略了解。
在zero_vec_like中创立nabla_b和nabla_w与咱们在Python中运用的列表十分相似。其中有一个波折让我有些懊丧,原本我想设法运用Array2::zeros创立一个初始化为零的数组,并将其传递给图画的切片或Vec,这样我就可以得到一个ArrayD实例。假如想取得一个Array2(明显这是一个二维数组,而不是一个通用的D维数组),我需求将一个元组传递给Array::zeros。可是,因为ndarray::shape会回来一个切片,我需求通过to_tuple函数手动将切片转换为元组。这种状况在Python很简略处理,但在Rust中,元组和切片之间的差异十分重要,就像在这个API中一样。
运用反向传达估量权重和误差更新的代码与python的完结结构十分相似。咱们分批操练每个示例图画,并取得二次本钱梯度的估量值作为误差和权重的函数:
let(delta_nabla_b, delta_nabla_w) = self.backprop(&training_data[*i]);
然后累加这些估量值:
for(nb, dnb)innabla_b.iter_mut().zip(delta_nabla_b.iter()) {
*nb += dnb;
}
for(nw, dnw)innabla_w.iter_mut().zip(delta_nabla_w.iter()) {
*nw += dnw;
}
在处理完小批次后,咱们依据学习速率调整权重和误差:
let nbatch = mini_batch_indices.len() as f64;
for(w, nw)inself.weights.iter_mut().zip(nabla_w.iter()) {
*w -= &nw.mapv(|x|x * eta / nbatch);
}
for(b, nb)inself.biases.iter_mut().zip(nabla_b.iter()) {
*b -= &nb.mapv(|x|x * eta / nbatch);
}
这个比如阐明与Python比较,在Rust中运用数组数据所支付的人力有十分大的差异。首要,咱们没有让这个数组乘以浮点数eta / nbatch,而是运用了Array::mapv,并界说了一个闭包,以矢量化的办法映射了整个数组。这种做法在Python中会很慢,因为函数调用十分慢。可是,在Rust中没有太大的差异。在做减法时,咱们还需求通过&借用mapv的回来值,避免在迭代时耗费数组数据。在编写Rust代码时需求细心考虑函数是否耗费数据或引证,因而在编写相似于Python的代码时,Rust的要求更高。另一方面,我愈加坚信我的代码在编译时是正确的。我不确定这段代码是否有必要,因为Rust真的很难写,或许是因为我的Rust编程经历远不及Python。
用Rust从头编写,全部都会好起来
到此为止,我用Rust编写的代码运转速度超过了我开始编写的未经优化的Python代码。可是,从Python这样的动态解说言语过渡到Rust这样的功能优先的编译言语,应该能到达10倍或更高功能,可是我只观察到大约2倍的提高。我该怎么丈量Rust代码的功能?走运的是,有一个十分优异的项目flamegraph(https://github.com/ferrous-systems/flamegraph)可以很简略地为Rust项目生成火焰图。这个东西为cargo添加了一个flamegraph子指令,因而你只需运转cargo flamegraph,就可以运转代码,然后写一个flamegraph的svg文件,就可以通过Web浏览器观测。
或许你曾经从未见过火焰图,因而在此简略地阐明一下,例程中程序的运转时刻份额与该例程的条形宽度成正比。主函数坐落图形的底部,主函数调用的函数堆叠在上面。你可以通过这个图形简略地了解哪些函数在程序中占用的时刻最多——图中十分“宽”的函数都在运转中占用了很多时刻,而十分高且宽的函数栈都代表其包括十分深化的栈调用,其代码的运转占用了很多时刻。通过以上火焰图,咱们可以看到我的程序大约一半的时刻都花在了dgemm_kernel_HASWELL等函数上,这些是OpenBLAS线性代数库中的函数。其他的时刻都花在了`update_mini_batch和分配数组中等数组操作上,而程序中其他部分的运转时刻可以忽略不计。
假如咱们为Python代码制作了一个相似的火焰图,则也会看到一个相似的形式——大部分时刻花在线性代数上(在反向传达例程中调用np.dot)。因而,因为Rust或Python中的大部分时刻都花在数值线性代数库中,所以咱们永久也无法得到10倍的提速。
实际状况或许比这更糟。上述我说到的书中有一个操练是运用向量化矩阵乘法重写Python代码。在这个办法中,每个小批次中全部图画的反向传达都需求通过一组矢量化矩阵乘法运算完结。这需求在二维和三维数组间运转矩阵乘法。因为每个矩阵乘法运算运用的数据量大于非向量化的状况,因而OpenBLAS可以更有效地运用CPU缓存和寄存器,终究可以更好地运用我的笔记本电脑上的CPU资源。重写的Python版别比Rust版别更快,但也只要大约两倍左右。
原则上,咱们可以用相同的办法优化Rust代码,可是ndarray包还不支撑高于二维的矩阵乘法。咱们也可以运用rayon等库完结小批次更新线程的并行化。我在自己的笔记本电脑上试了试,并没有看到任何提速,但或许更强壮的机器有更多CPU线程。我还测验了运用运用不同的初级线性代数完结,例如,运用Rust版的tensorflow和torch,但其时我觉得我彻底可以运用Python版的这些库。
Rust是否合适数据科学作业流程?
现在,我不得不说答案是“没有”。假如我需求编写可以将依靠性降到最低的、通过优化的初级代码,那么我肯定会运用Rust。可是,要想运用Rust彻底替代Python或C++,那么咱们需要求等候更安稳和更完善的包生态体系。
原文:https://ngoldbaum.github.io/posts/python-vs-rust-nn/
本文为 CSDN 翻译,转载请注明来历出处。
【End】
热 文推 荐