前馈神经网络
Feedforward Neural Network
网络结构(一般分两种)
- Back Propagation Networks - 反向传播网络
- RBF networks - 径向基函数神经网络
BP网络是所有的神经网络中结构最为简单的一种网络。
一般我们习惯把网络画成左边输入右边输出层的结构。
mark一个向量从输入层进入,经过网络作用,在输出层产生输出结果。
BP神经网络的网络结构是不固定的。
mark第二个神经网络有三层,两个隐藏层。
前馈神经网络,不要求对称,在不同的网络结构下产生不同的训练效果和运行特点。
每一层的神经元很多,层数也很多,整个模型不够直观。
最简单的BP网络
mark总共有两层,有一个隐藏层和一个输出层,一共两层。从第二层开始算一层。
输入x最简单化,x一维是一个实数。隐藏层h和输出层o结构。
mark由这两个函数所组成。一旦这里输入x,y之后就可以开始训练了。
网络结构-代码部分
# coding=utf-8import numpy as np# 定义神经网络结构class Network(object): def __init__(self, sizes): # [3,2,1] 输入层3个神经元,隐藏层2个,输出层1个 定义总共有多少层,每一层有多少个神经元 # 网络层数: 一共有多少层 self.num_layers = len(sizes) # 每层神经元的个数 self.sizes = sizes # 初始化每层的偏置 b self.biases = [np.random.randn(y, 1) for y in sizes[1:]] # self.biases = [] # # 去size从第一项到最后一项 sizes=[3,2,1] 我们只取2,1 # for y in sizes[1:]: # # 第一次循环时y为2,第二次为1 输入到隐藏,隐藏到输出: 一共两个偏置 # # random.randn 使用标准正态分布来初始化一个数组, 初始化一个y乘以1的数组 # # 从输入层到隐藏层有两个偏置,隐藏层到输出层有一个偏置 # self.biases.append(np.random.randn(y, 1)) # 初始化每层的权重 w self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])] # self.weights = [] # # 使用zip同时遍历两个列表 # for x, y in zip(sizes[:-1], sizes[1:]): # # sizes = [3,2,1] # # 输入层到隐藏层的连线总共有6条: 第一次我们x取3,y取2 # # 隐藏层到输出层的连线有2条, x我们取2,y取1 # self.weights.append(np.random.randn(y, x))# sigmoid激励函数(1/1+e的-z次方)def sigmoid(z): return 1.0 / (1.0 + np.exp(-z))# 创建网络net = Network([3, 2, 1])print(net.num_layers)print(net.sizes)print(net.biases)print(net.weights)
运行结果:
3[3, 2, 1][array([[ 0.95438103], [-0.16055862]]), array([[-0.84104886]])][array([[-0.50145733, -0.32197873, -0.55151483], [-1.57452067, -0.51783342, -0.54714269]]), array([[-0.1588291 , -0.01887209]])]
可以看到我们的神经网络有三层(算上了输入层) 每层的神经元个数分别为: 输入层3,隐藏层2,输出层1个神经元。
偏置为两组(net.biases[0] 或[1]), 从输入到隐藏层有两个偏置(因为隐藏层有两个神经元,每个神经元一个偏置); 隐藏层到输出层有一个偏置(因为输出层只有一个神经元)
而权重则是每条连线有一个权重值: 如输入到隐藏(3x2)一共有6个, 隐藏到输出(2x1)有2个。
net.weights是一个列表,第一项为输入层到隐藏层权重,第二项为隐藏到输出。
线性回归的训练
线性回归的训练,其实和基于统计的机器学习训练很相近。
如果你熟悉回归的话,你会觉得bp神经网络的训练很简单。
线性回归的训练过程: 一个线性回归的模型是怎么学到的,以及它学到了什么。
为理解BP神经网络的训练做铺垫。
一元线性回归
一元线性回归是所有机器学习中最简单的。
样本:
mark观察到很多数据对,有x有y。
观察记录(通过画图):
mark图中横坐标代表时间,单位是秒。纵坐标代表速度,单位是米每秒。
可以从图中直观的看到,时间和速度有一种线性关系。我们可以尝试画一条直线,从一堆点中穿越过去。
mark这条直线几乎可以满足所有点。我们就可以用这条直线来描述图片上的横纵坐标关系。
横坐标用x或t表示,纵坐标用y或s表示。
横纵坐标的关系:
mark因为直线并不是穿过所有的点,会存在误差。
误差:
参数e代表error,表示误差
y =wx+b+e
我们取定任何一个w和b时,只要带入x,y就会产生一个e.产生十个e.
把表达式变为:
mark这里的下标i就表示的是第几个样本: 也就是y的真实值减去我们的预测值(拟合值y帽子)
全局误差总量:
mark将所有单个样本的误差进行累加获得全局误差总量。
但是无论这个e是正是负都是误差,这就是残差。这里简单的累加就存在正负抵消。
对于每个e取绝对值或取平方保证非负数。
mark对于第二行表达式进行展开,得到第三行表达式。
表达式中的x与y是常数。里面的w和b才是未知数。
对于三式,提取公因式并合并就可以得到如下图公式。
mark其中的ABCDEF全部都是常数系数。未知数是w和b
接下来我们要做的事情就是找到一个比较合适的w和b,使得loss值越小越好。
越接近0,说明我们拟合的误差越小。要让我们的直线,尽可能离样本点近。
mark我们找到上面方程ABCDEF的近似方程:
mark这个图形在三维空间中像一个碗。和二维空间中的抛物线很像,肯定存在极值,极值在碗底。
只要我们求的碗底的(x,y)我们就求的了最佳参数(w,b),这里只是符号字母不同,意义是一样的。只是把其中的w,
b替换成了x和y
梯度下降法
具体怎么求w和b的值:
求解w和b的值的方法很多,这里我们就使用梯度下降算法。
一元凸函数
mark我们之前讲的是二元凸函数。
mark三元及以上凸函数,无法画图查看。只能靠自己想象。
mark求极小值,这个是有技巧的,使用挪挪看的方法来求。
我们不知道一元函数fx的极值点,我们可以随便取一点,如:(3,11)
我们再在这一点的左右都进行取值。看看哪一边更低。
mark可以看到左边的点更小一点。下次我们就可以以2.8这个点为基准。向
mark挪动。然后左看2.6 右看3.0。2.6会更小一点。一直往过挪动。
这里我们人为规定每次挪动0.2,通常情况下我们不确定挪动多少合适。
我们希望有一种方法可以该多挪的时候多挪,该少挪的时候少挪。挪到位了就不要再挪了。
梯度下降算法就可以解决这个问题。让我们的每次挪动不固定在0.2,而是离极值远的地方挪的快一点,而离极值近的地方挪的慢一些。挪到位了就不要挪动了。
梯度下降算法的更新公式。
mark markXn+1 和 Xn 分别代表两个临近点的x值。Xn+1是Xn更新后的下一次迭代值。
学习率:挪动步长的基数,步长。学习率设置大的话救挪动的多,设置的小的话就挪动的少。
f'(x)就是x点的导数,物理意义是切线的斜率。
梯度下降更新一元函数
mark取x=3, 导数就是(3,11)这一点的切线斜率。
markmark直接求出导函数,带入x。
假设学习率为0.1,那么我们的学习率x导数值就是我们下一步要挪动的步长
由更新公式:
mark可以得到
mark然后我们进行下一次迭代,
mark每一次移动的步长在逐渐的减小。原因: 临近整个函数底部,斜率是在逐渐降低的。
导致:
mark的绝对值在逐渐的减小。那么更新时的x的改变量也会逐渐的减小。
mark观察我们的更新公式会发现没有之前我们的左右取点看一看的过程,而是直接做更新。
原因直接讲出来,因为当我们取x=3这一点的导数值(斜率为正值)
mark那么:
mark这一项就是一个负数。所以更新后的Xn+1就会变小。朝着曲线底部方向挪动。
如果Xn 取-3就会在曲线的左侧,此时这一点的斜率是一个负数。
mark那么:
mark这一项就会是一个正数。更新之后的Xn+1就会变大,依然是朝着函数底部的方向。
所以X取正三,还是负三都能保证这个函数朝着底部移动。
二元凸函数
之前我们讲解了一元凸函数,可以通过编程方式解决极值问题。
mark二元凸函数我们用同样的方法解决极值问题。
同样我们在这个平面上随便找一个点(x0,y0),然后想把它挪动到极值点上去。
跟原来的思路有区别,原来一元凸函数我们有两个方向去尝试,可以先看眼左边,再看眼右边。哪一边离极值点近就像哪一边先挪动。
而二元凸函数我们就有四个方向去尝试。
mark在这个更新公式中我们发现并不需要真的去两边都去尝试一下。这个更新方程本身就可以在一个维度上来识别需要向哪个方向移动。
mark现在在两个维度上的更新方程简化之后就是如上图所示公式。
偏导数。
mark上图表示的是x偏导,曲面上的点沿着平行于x轴方向的切线。
mark沿着x轴方向的切线斜率。
y轴方向切线
mark mark沿着y轴方向的切线斜率
更新
mark从数学来讲,两者的求法一致。我们的第二种写法显得不专业,但是只是为了表示与一元函数求导没有差别。
mark假设我们有一点(3,4),代入之后可求得值为64
mark更新方程:
mark这个时候就可以通过更新方程进行工作了。但是计算机的有限计算能力,实际的深度学习网络中极有可能有上万个维度的值要更新,效率层面我们希望这种更新能产生最高效的收敛效果。也就是通过多次迭代逐步逼近我们想要的值。如果准确度相当的情况下,收敛快的方法更受我们的欢迎。
更新原则
对于梯度下降算法来说,里面有多个维度可以选择。
mark对于f(x,y)就有两个维度可以选择。我们使用什么原则可以使收敛的速度最快。
如果在一个三维的空间中,我们想从山顶往山下走。最快的方法:沿着山坡上最陡峭的方向向下最快。
mark梯度: 反着写的倒三角形,等于两个方向上的偏导,一个是x的偏导一个是y的偏导。
如果每次进行更新的时候,你就可以使用学习率(一塔)乘以这两个方向上的偏导。各自完成自己的更新量。
假如我们函数中的变量不止两个,而是有一千种。
它表示的形式就是:
mark求出了这1000个参数的偏导数。然后乘以学习率,更新1000个w变量
mark其中w的上标i表示这是第几个w。w的下标n和n+1表示的是第几次迭代。也就是我们完成一次迭代需要对1000个w分别做更新。
这里我们就讲解完梯度下降算法在多元凸函数上的更新步骤和更新原则。
开始训练(和之前讲的一元凸函数训练方法是一样的):
需要一个描述残差loss的函数。
mark接着我们可以使用若干个w来表述这个函数。然后我们可以用梯度下降算法用最快方法去更新w的各个维度。最后满足loss极值点的位置就是我们要找的w。
mark神经网络的训练
我们已经介绍了线性回归的训练过程,那么我们再来看一下神经网络的训练过程。
mark这是我们定义的简单的两层简单bp网络。第一层是隐藏层h 第二层是输出层o
隐藏层h和输出层o都是由两部分组成。
mark第一部分是线性单元,第二部分是非线性部分。第一行为隐藏层的输入x经过wh和bh的处理变成Zh,Zh又作为非线性单元的输入。
输出层同理,输入变为了Yh。
训练过程
- 初始化w,b
- Loss(w,b)
- 挪动w,b 逐步变化, 直到Loss足够小。
样本:
mark计算机自己学习出方程中的四个待定系数。Wh Bh Wo Bo
如果你是做图片分类,你就需要给每个图片打上一个标签。
markx1 猫 x2 狗 xn标对应标签
前向传播
mark根据网络中两个表达式的描述。可以带入x1:
mark表达式的映射关系就变成上图所示。
由x1和y1所带来的误差值我们也可以定义了
mark这是一个样本的Loss,也就是残差平方。 然后累加求和得到下面:
mark如果预测出来的结果yo1和y很接近,loss1就会比较小。
因为我们有10对训练数据,得到十个loss,Loss累加。
这就是神经网络中的前向传播: 输入数据通过网络一层一层的作用一直向前传播。
前向传播的代码实现
# coding=utf-8import randomimport numpy as npclass Network(object): def __init__(self, sizes): # 网络层数 self.num_layers = len(sizes) # 网络每层神经元个数 self.sizes = sizes # 初始化每层的偏置 self.biases = [np.random.randn(y, 1) for y in sizes[1:]] # 初始化每层的权重 self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])] # 梯度下降 # GD函数有两个变量: 训练数据集, 需要训练的轮数 def GD(self, training_data, epochs): # 开始训练 循环每一个epochs: 定义epochs数值为几,就循环几次 for j in range(epochs): # 洗牌 打乱训练数据 shuffle random.shuffle(training_data) # 让每一次训练的时候,训练数据的顺序不同 # 训练每一个数据, 使用x和y来取训练数据data和它对应的y for x, y in training_data: # 使用update方法进行前向传播 self.update(x, y) # 每个epoch 完成,打印我们已经训练到了第几个epoch print("Epoch {0} complete".format(j)) # 前向传播 def update(self, x, y): # 传入输入的训练数据, activation = x # 保存每一层的激励值a=sigmoid(z) z=wx+b # 第一层时输入数据就是它的激励值 activations = [x] # 保存每一层的z=wx+b zs = [] # 前向传播 # 使用for循环遍历每一层的偏置与权重:同时取第一层的偏置和权重 for b, w in zip(self.biases, self.weights): # 计算每层的z # dot是点乘方法: 把两个数组进行点乘,对于二维数组相当于矩阵乘法。 # 一维数组相当于向量的内积 z = np.dot(w, activation) + b # 保存每层的z zs.append(z) # 计算每层的a activation = sigmoid(z) # 保存每一层的a activations.append(activation)def sigmoid(z): return 1.0 / (1.0 + np.exp(-z))
计算每一层的z, 再计算每一层的a。接着保存每一层的z和a,为后面的反向1传播做准备。
反向传播更新参数
可以开始挪动Wh Bh Wo Bo四个待定系数,来逐步减小Loss值。
这个在之前的线性回归的训练过程中我们已经讲过了。
也就是需要四个更新公式来帮我们更新。
mark mark和之前线性回归中的更新公式大同小异。只不过线性回归当中是一个三维空间。
我们去找碗底的过程。这里变成了一个五维空间我们去找碗底的过程。
在神经网络中去更新网络中的待定系数的过程,叫做反向更新。因为我们是从最后一层开始,倒数第二层,倒数第三层。这样一层一层的反向更新w和b
如何求这四个公式中的偏loss 偏wh 偏loss 偏bh 偏loss 偏wo 偏loss 偏bo
mark这四个值到底怎么求?
mark我们把我们的损失函数loss的方程改变一下。在前面加上二分之一。这样是为了我们后面求导方便约分。
根据链式法则可以得到:
mark又由于Zo=WoYh+Bo 所以Zo偏Bo求导的值为1.所以得到
mark看一下下图中如何求
mark由于最后一层的Yo就是sigmoid(z) 就等于:
mark变成了求sigmoid(z)的导数。
mark特性: sigmoid函数求导出来是它本身乘以(1-sigmoid函数)
再来看一下前面这一项:
mark如果我们把loss的表达式带进来,求得的偏yo就等于:
mark mark也就是预测值减去真实值。此时综合一下
mark markyo是网络预测出来的结果是一个已知量, yi是我们的标签,已知值。
Zo也可以通过前向更新,WoYh+Bo得到。也是一个已知值。
反向更新更新w和b,需要下面四个方程配合。
markmark我们可以求出最后一层的偏loss/偏b 等于的他0:
如果是倒数第二层或倒数第三层也就是非最后一层的偏loss/偏b,你就可以用(的他h)这个
公式去计算它的值。
同理,偏loss/偏w 如果是最后一层,你可以把(的他0代进来),如果不是最后一层你可以把的他h带进来。这样你就可以求出偏loss/偏w的值。
利用四个反向更新公式,一步一步的慢慢改变w和b。直到loss最小。
网络中定义的loss函数是一个二次损失函数,如果你使用的不是二次损失函数,那么最后推出来的结果就不是这四个更新方程。需要自己来根据实际情况进行推导。
如果你使用的是框架的话,这些推导的步骤不需要你自己去做,你只需要设置参数就可以了。
反向传播更新代码实现
在梯度下降GD方法中加入反向传播的参数保存
# 反向: 保存每层偏导 # 反向: 取到每一层的偏置值,取到它的形状,以这个形状创建零矩阵 nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights]
update方法中加入偏导的保存
# 前向传播 def update(self, x, y): # 保存每层偏倒 nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights]
将z和activation保存了之后就可以进行反向更新了。
markmark网络预测的值减去它的真实值之后,点乘:
Zo也就是最后一层的wx+b的值。代码中我们已经把Zo计算出来保存到了zs里面。
最后一层的误差delta就等于我们自定义的cost_derivative函数计算Yo-Yi,最后一层的预测值就是activations[-1],取最后一层计算出来的预测值yo,接着乘以sigmoid_prime函数来计算最后一层sigmoid的偏导。
# 反向更新了: 从倒数第一层开始 # 计算最后一层的误差 delta = self.cost_derivative(activations[-1], y) * sigmoid_prime(zs[-1])
预测值-真实值
def cost_derivative(self, output_activation, y): return (output_activation - y)
sigmoid(Zo)的偏导: 导数的一个特性
def sigmoid_prime(z): return sigmoid(z) * (1 - sigmoid(z))
# 最后一层权重和偏置的倒数 # 偏loos/偏b = delta # 偏loss/偏w = 倒数第二层y 乘以 delta nabla_b[-1] = delta # transpose转置操作 nabla_w[-1] = np.dot(delta, activations[-2].transpose())
可以求倒数第二层直到第一层的权重和偏置的导数。
从导数第二层开始,所以(2, self.num_layers) zs[-l]
先正向传播后反向更新。求得了每一层的权重和偏置的导数。
在训练中,保存update返回的结果
# 训练每一个数据 for x, y in training_data: delta_nable_b, delta_nabla_w = self.update(x, y) # 保存一次训练网络中每层的偏倒 nabla_b = [nb + dnb for nb, dnb in zip(nabla_b, delta_nable_b)] nabla_w = [nw + dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
因为nb是零矩阵,加上dnb得出来的结果就是dnb
之后就可以进行真正的反向更新了。
# eta学习率 # 更新权重和偏置 Wn+1 = wn - eta * nw self.weights = [w - (eta) * nw for w, nw in zip(self.weights, nabla_w)] self.biases = [b - (eta) * nb for b, nb in zip(self.biases, nabla_b)]
循环遍历每一层的权重和权重的导数。Wn+1 = wn - eta * nw
随机梯度下降算法
在介绍梯度下降算法时我们已经介绍了损失函数的公式了。在损失函数的公式里面n表示拥有n个样本。表示求导数的过程中n个样本都要参与计算。
mark可是n的值往往非常大,每个样本都有x个维度,有可能数万,有可能数百万。这种计算需要大量的时间。需要有一种方式缩短这种计算时间。
mark随机梯度下降算法其实没有什么新奇的地方,就是统计学中的抽样。我们从整体的数据中随机的抽取一部分样本,这些样本的特征已经一定程度上代表样本集的特征。
就像统计人类的特征,不需要世界人口普查,只需要抽取一部分具有代表性的特征。只要他们能覆盖所有的人口,年龄,性别。
对这一部分抽取出来的样本进行归纳总结就可以了。
SGD中的几个名词
- 减少计算量
mini-batch: 从训练样本中随机选取一些数据
epoch: 一次训练一个mini-batch,直到把所有训练数据都用一遍。
这种情况下w和b的更新公式也需要做相应的修改。只要对后面一项除以m就可以了。
markm的大小就是mini-batch的大小。从训练样本中随机抽取的数据的大小就是m的大小。
SGD代码更新
在了解了随机梯度下降算法之后我们又可以更新之前的算法。
第19行,把梯度下降函数改为SGD随机梯度下降。函数中再多添加一个参数叫做mini_batch_size
# 随机梯度下降 def SGD(self, training_data, epochs, mini_batch_size, eta): # 取出训练数据总个数 n = len(training_data)
在洗牌之后,我们循环mini_batch_size
# mini_batch mini_batches = [training_data[k:k + mini_batch_size] for k in range(0, n, mini_batch_size)]
我们循环0-n,每隔mini_batch_size取一个k。training_data从k开始一直到k加上minibatch
之前是训练整个数据集,我们现在只需要训练minibatch大小个数据就可以了
# 训练mini_batch for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta)
我们需要实现一个新的函数。update_mini_batch
# 更新mini_batch def update_mini_batch(self, mini_batch, eta): # 保存每层偏倒 nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] # 训练每一个mini_batch for x, y in mini_batch: delta_nable_b, delta_nabla_w = self.update(x, y) # 保存一次训练网络中每层的偏倒 nabla_b = [nb + dnb for nb, dnb in zip(nabla_b, delta_nable_b)] nabla_w = [nw + dnw for nw, dnw in zip(nabla_w, delta_nabla_w)] # 更新权重和偏置 Wn+1 = wn - eta * nw self.weights = [w - (eta / len(mini_batch)) * nw for w, nw in zip(self.weights, nabla_w)] self.biases = [b - (eta / len(mini_batch)) * nb for b, nb in zip(self.biases, nabla_b)]
将training_data改为mini_batch
更新权重和偏置的时候使用eta除以minibatch的总个数。
前馈神经网络手写数字识别
在实现了我们的神经网络之后,我们来看一下如何使用我们刚才的神经网络来做图片的识别。
案例: 手写数字识别
mark markMNIST dataset
每一张图片为28,28的。数字总共有10种。
如何用我们之前实现的神经网络来实现手写数字识别
随机梯度下降,正向传播,反向更新。
if __name__ == '__main__': import mnist_loader traning_data, validation_data, test_data = mnist_loader.load_data_wrapper()
从mnist中取训练数据,验证数据,测试数据。
使用我们的Network类定义我们的网络。定义一个三层的网络,输入层为28,28共784个神经元。隐藏层我们定义30个神经元,输出层有十种数字。 net = Network([784, 30, 10])
net = Network([784, 30, 10])
调用net的SGD方法训练我们的模型:
传入参数: 训练数据集,epoch数量,minibatch大小,学习率0.5
net.SGD(traning_data, 30, 10, 0.5, test_data=test_data)
我们再添加一个我们的参数: test_data。每一轮训练完成之后看我们的神经网络在测试集上的表现如何。
def SGD(self, training_data, epochs, mini_batch_size, eta, test_data=None): if test_data: n_test = len(test_data)
在训练完成之后进行测试集上表现的测试
# 测试集上的表现 if test_data: print("Epoch {0}: {1} / {2}".format( j, self.evaluate(test_data), n_test))
自定义我们的评估evaluate方法,看当前网络总共预测对了多少:
def evaluate(self, test_data): test_results = [(np.argmax(self.feedforward(x)), y) for (x, y) in test_data] return sum(int(x == y) for (x, y) in test_results)
循环遍历test_data中的数据x和label y。可以让我们取出来的x经过一个函数feedforward
前向传播就是我们网络的预测结果。因为我们刚才定义的输出层有十个神经元。
每一维代表它是这个数字的可能性。调用argmax取这十维中最大的一个数字。最大的数字就是我们经过网络预测出来最可能的结果。每一对预测结果和真实标签组成我们的test_results。
如果一样就返回1,不一样就返回0。返回10000个比较结果,0或者1.求和一下就是验证正确的总数。
feedforward就是我们的前向传播函数。输入值a
def feedforward(self, a): for b, w in zip(self.biases, self.weights): a = sigmoid(np.dot(w, a) + b) return a
遍历当前网络所有层上的w和b。
调用每一个神经元都由线性单元wx+b 经过激励函数组成。网络的预测结果,然后把网络的预测结果返回出去。
mark解决几个Python3下的报错。
- no module named cPickle
Python3下cpickle改名为了pickle。直接修改修改使用pickle就行了。
- 报错2
UnicodeDecodeError: 'ascii' codec can't decode byte 0x90 in position 614: or
这个是因为pickle要指定编码格式。
training_data, validation_data, test_data = pickle.load(f,encoding='bytes')
添加encoding参数。
- 报错3:
TypeError: object of type 'zip' has no len()
将zip对象,强制转化为list类型。
training_data = list(zip(training_inputs, training_results))
训练结果
mark隐藏层30 输出层10(代表10种数字0-9) 学习率0.5
跑完30轮之后准确率还蛮高的93%。
mark只改变了隐藏层的神经元个数,准确率提高到了94%