神经网络入门——一个简洁的神经网络
直接上代码是最有效的学习方式。一个用 BP 算法训练的神经网络尝试着用输入去预测输出。
考虑以上情形:给定三列输入,试着去预测对应的一列输出。我们可以通过简单测量输入与输出值的数据来解决这一问题。这样一来,我们可以发现最左边的一列输入值和输出值是完美匹配/完全相关的。直观意义上来讲,反向传播算法便是通过这种方式来衡量数据间统计关系进而得到模型的。下面直入正题,动手实践。
import numpy as np
# input dataset
# 第一层L0 也是输入值
l0 = X = np.array([[0, 0, 1],
[0, 1, 1],
[1, 0, 1],
[1, 1, 1]])
# output dataset
y = np.array([[0, 0, 1, 1]]).T
# sigmoid function
def nonlin(x, deriv=False):
if (deriv == True):
return x * (1 - x)
return 1 / (1 + np.exp(-x))
# seed random numbers to make calculation
# deterministic (just a good practice)
np.random.seed(1)
# initialize weights randomly with mean 0
syn0 = 2 * np.random.random((3, 1)) - 1
print(\'syn0 is {0}\'.format(syn0))
for iter in range(10000):
# forward propagation
l1 = nonlin(np.dot(l0, syn0))
# how much did we miss?
l1_error = y - l1
# multiply how much we missed by the
# slope of the sigmoid at the values in l1
l1_delta = l1_error * nonlin(l1, True)
# update weights
syn0 += np.dot(l0.T, l1_delta)
print(l1)
Output After Training:
[[ 0.00966449]
[ 0.00786506]
[ 0.99358898]
[ 0.99211957]]
| 变量 | 定义说明 | |
|---|---|---|
| X | 输入数据集,形式为矩阵,每 1 行代表 1 个训练样本。 | |
| y | 输出数据集,形式为矩阵,每 1 行代表 1 个训练样本。 | |
| l0 | 网络第 1 层,即网络输入层。 | |
| l1 | 网络第 2 层,常称作隐藏层。 | |
| syn0 | 第一层权值,突触 0 ,连接 l0 层与 l1 层。 | |
| * | 逐元素相乘,故两等长向量相乘等同于其对等元素分别相乘,结果为同等长度的向量。 | |
| – | 元素相减,故两等长向量相减等同于其对等元素分别相减,结果为同等长度的向量。 | |
| x.dot(y) | 若 x 和 y 为向量,则进行点积操作;若均为矩阵,则进行矩阵相乘操作;若其中之一为矩阵,则进行向量与矩阵相乘操作。 |
正如在“训练后结果输出”中看到的,程序正确执行!在描述具体过程之前,我建议读者事先去尝试理解并运行下代码,对算法程序的工作方式有一个直观的感受。最好能够在 ipython notebook 中原封不动地跑通以上程序(或者你想自己写个脚本也行,但我还是强烈推荐 notebook )。下面是对理解程序有帮助的几个关键地方:
- 对比 l1 层在首次迭代和最后一次迭代时的状态。
- 仔细察看 “nonlin” 函数,正是它将一个概率值作为输出提供给我们。
- 仔细观察在迭代过程中,l1_error 是如何变化的。
- 将第 36 行中的表达式拆开来分析,大部分秘密武器就在这里面。
- 仔细理解第 39 行代码,网络中所有操作都是在为这步运算做准备。
下面,让我们一行一行地把代码过一遍。
建议:用两个屏幕来打开这篇博客,这样你就能对照着代码来阅读文章。在博客撰写时,我也正是这么做的。 :)
第 1 行:这里导入一个名叫 numpy 的线性代数工具库,它是本程序中唯一的外部依赖。
第 4 行:这里是我们的“非线性”部分。虽然它可以是许多种函数,但在这里,使用的非线性映射为一个称作 “sigmoid” 的函数。Sigmoid 函数可以将任何值都映射到一个位于 0 到 1 范围内的值。通过它,我们可以将实数转化为概率值。对于神经网络的训练, Sigmoid 函数也有其它几个非常不错的特性。
第 5 行: 注意,通过 “nonlin” 函数体还能得到 sigmod 函数的导数(当形参 deriv 为 True 时)。Sigmoid 函数优异特性之一,在于只用它的输出值便可以得到其导数值。若 Sigmoid 的输出值用变量 out 表示,则其导数值可简单通过式子 out *(1-out) 得到,这是非常高效的。
若你对求导还不太熟悉,那么你可以这样理解:导数就是 sigmod 函数曲线在给定点上的斜率(如上图所示,曲线上不同的点对应的斜率不同)。有关更多导数方面的知识,可以参考可汗学院的导数求解教程。
第 10 行:这行代码将我们的输入数据集初始化为 numpy 中的矩阵。每一行为一个“训练实例”,每一列的对应着一个输入节点。这样,我们的神经网络便有 3 个输入节点,4 个训练实例。
第 16 行:这行代码对输出数据集进行初始化。在本例中,为了节省空间,我以水平格式( 1 行 4 列)定义生成了数据集。“.T” 为转置函数。经转置后,该 y 矩阵便包含 4 行 1 列。同我们的输入一致,每一行是一个训练实例,而每一列(仅有一列)对应一个输出节点。因此,我们的网络含有 3 个输入, 1 个输出。
第 20 行:为你的随机数设定产生种子是一个良好的习惯。这样一来,你得到的权重初始化集仍是随机分布的,但每次开始训练时,得到的权重初始集分布都是完全一致的。这便于观察你的策略变动是如何影响网络训练的。
第 23 行:这行代码实现了该神经网络权重矩阵的初始化操作。用 “syn0” 来代指 “零号突触”(即“输入层-第一层隐层”间权重矩阵)。由于我们的神经网络只有 2 层(输入层与输出层),因此只需要一个权重矩阵来连接它们。权重矩阵维度为(3,1),是因为神经网络有 3 个输入和 1 个输出。换种方式来讲,也就是 l0 层大小为 3 , l1 层大小为 1 。因此,要想将 l0 层的每个神经元节点与 l1 层的每个神经元节点相连,就需要一个维度大小为(3,1)的连接矩阵。:)
同时,要注意到随机初始化的权重矩阵均值为 0 。关于权重的初始化,里面可有不少学问。因为我们现在还只是练习,所以在权值初始化时设定均值为 0 就可以了。
另一个认识就是,所谓的“神经网络”实际上就是这个权值矩阵。虽然有“层” l0 和 l1 ,但它们都是基于数据集的瞬时值,即层的输入输出状态随不同输入数据而不同,这些状态是不需要保存的。在学习训练过程中,只需存储 syn0 权值矩阵。
第 25 行:本行代码开始就是神经网络训练的代码了。本 for 循环迭代式地多次执行训练代码,使得我们的网络能更好地拟合训练集。
第 28 行:可知,网络第一层 l0 就是我们的输入数据,关于这点,下面作进一步阐述。还记得 X 包含 4 个训练实例(行)吧?在该部分实现中,我们将同时对所有的实例进行处理,这种训练方式称作“整批”训练。因此,虽然我们有 4 个不同的 l0 行,但你可以将其整体视为单个训练实例,这样做并没有什么差别。(我们可以在不改动一行代码的前提下,一次性装入 1000 个甚至 10000 个实例)。
第 29 行:这是神经网络的前向预测阶段。基本上,首先让网络基于给定输入“试着”去预测输出。接着,我们将研究预测效果如何,以至于作出一些调整,使得在每次迭代过程中网络能够表现地更好一点。
(4 x 3) dot (3 x 1) = (4 x 1)
本行代码包含两个步骤。首先,将 l0 与 syn0 进行矩阵相乘。然后,将计算结果传递给 sigmoid 函数。具体考虑到各个矩阵的维度:
(4 x 3) dot (3 x 1) = (4 x 1)
矩阵相乘是有约束的,比如等式靠中间的两个维度必须一致。而最终产生的矩阵,其行数为第一个矩阵的行数,列数则为第二个矩阵的列数。
由于装入了 4 个训练实例,因此最终得到了 4 个猜测结果,即一个(4 x 1)的矩阵。每一个输出都对应,给定输入下网络对正确结果的一个猜测。也许这也能直观地解释:为什么我们可以“载入”任意数目的训练实例。在这种情况下,矩阵乘法仍是奏效的。
第 32 行:这样,对于每一输入,可知 l1 都有对应的一个“猜测”结果。那么通过将真实的结果(y)与猜测结果(l1)作减,就可以对比得到网络预测的效果怎么样。l1_error 是一个有正数和负数组成的向量,它可以反映出网络的误差有多大。
第 36 行:现在,我们要碰到干货了!这里就是秘密武器所在!本行代码信息量比较大,所以将它拆成两部分来分析。
第一部分:求导
|
1
|
nonlin(l1,True)
|
如果 l1 可表示成 3 个点,如下图所示,以上代码就可产生图中的三条斜线。注意到,如在 x=2.0 处(绿色点)输出值很大时,及如在x=-1.0 处(紫色点)输出值很小时,斜线都非常十分平缓。如你所见,斜度最高的点位于 x=0 处(蓝色点)。这一特性非常重要。另外也可发现,所有的导数值都在 0 到 1 范围之内。
整体认识:误差项加权导数值
|
1
|
l1_delta = l1_error * nonlin(l1,True)
|
当然,“误差项加权导数值”这个名词在数学上还有更为严谨的描述,不过我觉得这个定义准确地捕捉到了算法的意图。 l1_error 是一个(4,1)大小的矩阵,nonlin(l1,True)返回的便是一个(4,1)的矩阵。而我们所做的就是将其“逐元素地”相乘,得到的是一个(4,1)大小的矩阵 l1_delta ,它的每一个元素就是元素相乘的结果。
当我们将“斜率”乘上误差时,实际上就在以高确信度减小预测误差。回过头来看下 sigmoid 函数曲线图!当斜率非常平缓时(接近于 0),那么网络输出要么是一个很大的值,要么是一个很小的值。这就意味着网络十分确定是否是这种情况,或是另一种情况。然而,如果网络的判定结果对应(x = 0.5,y = 0.5)附近时,它便就不那么确定了。对于这种“似是而非”预测情形,我们对其做最大的调整,而对确定的情形则不多做处理,乘上一个接近于 0 的数,则对应的调整量便可忽略不计。
第 39 行:现在,更新网络已准备就绪!下面一起来看下一个简单的训练示例。
在这个训练示例中,我们已经为权值更新做好了一切准备。下面让我们来更新最左边的权值(9.5)。
权值更新量 = 输入值 * l1_delta
对于最左边的权值,在上式中便是 1.0 乘上 l1_delta 的值。可以想得到,这对权值 9.5 的增量是可以忽略不计的。为什么只有这么小的更新量呢?是因为我们对于预测结果十分确信,而且预测结果有很大把握是正确的。误差和斜率都偏小时,便意味着一个较小的更新量。考虑所有的连接权值,这三个权值的增量都是非常小的。
然而,由于采取的是“整批”训练的机制,因此上述更新步骤是在全部的 4 个训练实例上进行的,这看上去也有点类似于图像。那么,第 39 行做了什么事情呢?在这简单的一行代码中,它共完成了下面几个操作:首先计算每一个训练实例中每一个权值对应的权值更新量,再将每个权值的所有更新量累加起来,接着更新这些权值。亲自推导下这个矩阵相乘操作,你便能明白它是如何做到这一点的。
重点结论:
现在,我们已经知晓神经网络是如何进行更新的。回过头来看看训练数据,作一些深入思考。 当输入和输出均为 1 时,我们增加它们间的连接权重;当输入为 1 而输出为 0 时,我们减小其连接权重。
因此,在如下 4 个训练示例中,第一个输入结点与输出节点间的权值将持续增大或者保持不变,而其他两个权值在训练过程中表现为同时增大或者减小(忽略中间过程)。这种现象便使得网络能够基于输入与输出间的联系进行学习。