1. 模型图示
感知机仅能解决线性的问题,这个局限性使得其无法适应多数的实际应用。因此人们提出了神经网络。如图2.1所示。
图2.1 神经网络
2. 相关技术
技术2.1 隐藏层
从结构上看,神经网络有多层,比感知机复杂。除了输入层、输出层,还增加了1个或多个隐藏层。输入层与输出层节点的个数由具体应用所确定,这个和感知机没有区别。隐藏层的层数、每层节点个数(这两个可称为神经网络的参数),则对神经网络的效果起到很大作用。
对于神经网络的新手玩家而言,针对具体应用,调整这些参数既可能是有意思的活儿,也可能是枯燥的活儿。
仅仅增加隐藏层能否增强网络的能力?很遗憾,答案是否定的。用简单的推导可以证明,增加隐藏层(可以是多层)并未改变线性的本质,和单层结构的作用完全相同。
技术2.2 激活函数
激函数才是使得神经网络变得无所不能的技术。如图2.1所示,第1层的输出是 z = ( z 1 , z 2 , z 3 ) \mathbf{z} = (z_1, z_2, z_3) z=(z1,z2,z3),它们经过变换,成为 a = ( a 1 , a 2 , a 3 ) \mathbf{a} = (a_1, a_2, a_3) a=(a1,a2,a3)。干这个事儿的就是激活函数。
sigmoid
f
(
x
)
=
1
1
+
e
−
x
.
(1)
f(x) = \frac{1}{1 + e^{-x}}. \tag{1}
f(x)=1+e−x1.(1)
它有良好的数学性质:
- 把数据从 ( − ∞ , + ∞ ) (-\infty, +\infty) (−∞,+∞)映射到 ( 0 , + 1 ) (0, +1) (0,+1);
- 从0往两边,变化速度很快,然后平稳;
-
f
′
(
x
)
=
f
(
x
)
(
1
−
f
(
x
)
)
f'(x) = f(x) (1 - f(x))
f′(x)=f(x)(1−f(x))
显然,经过激活函数后,神经网络所表达的变换肯定不是线性的了。
图2.2 sigmoid函数
tanh
f
(
x
)
=
e
x
−
e
−
x
e
x
+
e
−
x
.
(2)
f(x) = \frac{e^x - e^{-x} }{e^x + e^{-x}}. \tag{2}
f(x)=ex+e−xex−e−x.(2)
它也有良好的数学性质:
- 把数据从 ( − ∞ , + ∞ ) (-\infty, +\infty) (−∞,+∞) 映射到 ( − 1 , + 1 ) (-1, +1) (−1,+1);
- 从0往两边,变化速度很快,然后平稳;
-
f
′
(
x
)
=
1
−
f
2
(
x
)
f'(x) = 1 - f^2(x)
f′(x)=1−f2(x)
图2.3 tanh函数
技术2.3 多层反馈
一般将神经网络称为BP神经网络,BP就是Backpropagation。从输出层可以向输入层反馈,逐步调整每层的权重。越靠近输入层,梯度越小,权重改变越小,因此一般5层就够了。
权值更新的具体步骤如下:
Step 1. 根据激活函数调整loss. 使用
L
1
L_1
L1 损失时, 令输出层的节点值为
y
^
i
\hat{y}_i
y^i, 标准的输出为
y
i
y_i
yi之间, 激活函数为 Sigmoid. 则输出节点的误差为:
f
′
(
y
^
i
)
(
y
^
i
−
y
i
)
=
y
^
i
(
1
−
y
^
i
)
(
y
^
i
−
y
i
)
.
(3)
f'(\hat{y}_i)(\hat{y}_i - y_i) = \hat{y}_i (1 - \hat{y}_i) (\hat{y}_i - y_i). \tag{3}
f′(y^i)(y^i−yi)=y^i(1−y^i)(y^i−yi).(3)
Step 2. 将这些 loss 反向传递, 由前一层的各条边承担. 令
n
i
n_i
ni 表示第
i
i
i层的节点数,
d
l
i
d_{li}
dli表示第
l
l
l 层的第
i
i
i 个节点的当前数据,
e
l
i
e_{li}
eli 表示第
l
l
l 层的第
i
i
i 个节点的当前误差,
w
l
i
j
w_{lij}
wlij 表示第
l
l
l 层第
i
i
i 个节点与第
l
+
1
l + 1
l+1 层第
j
j
j 个节点之间边的权重. 则边的权重更新为
w
l
i
j
=
w
l
i
j
+
δ
e
l
+
1
,
j
d
l
i
,
(4)
w_{lij} = w_{lij} + \delta e_{l + 1, j} d_{li}\tag{4},
wlij=wlij+δel+1,jdli,(4)
其中
δ
\delta
δ 为学习速度. 如果还要考虑动量调整, 式(4) 可以加一个分量.
节点的误差更新为
e
l
i
=
d
l
i
(
1
−
d
l
i
)
∑
k
=
1
n
j
+
1
e
l
+
1
,
k
w
l
i
k
,
(5)
e_{li} = d_{li} (1 - d_{li}) \sum_{k = 1}^{n_{j + 1}} e_{l + 1, k} w_{lik}\tag{5},
eli=dli(1−dli)k=1∑nj+1el+1,kwlik,(5)
其中,前半部分与激活函数求导有关, 与式(3)同理; 后半部分则与下层各节点上的损失, 以及边的权重有关.
技术2.4 输出层表示
神经网络的输出层是多样的,既可是一个节点,也可以是多个。这使得它可以应对不同的任务。例如,回归时只需要一个节点;分类( c c c 个类别)需要 c c c 个节点,每个节点表示一个 [ 0 , 1 ] [0, 1] [0,1] 区间的实数值,哪个节点的值大,就预测为相应类别;多标签学习( l l l 个标签)需要 l l l 个节点,分别表示属于各个类别的可能性;围棋程序,输出为361个节点,分别表示下一步放在相应位置的胜率。
3. 代码分析
本节给出 java 代码分析,原代码只有70行,点击访问。我加入读数据代码后放到gibhub,点击访问。
3.1 模型定义
//层数, 包含输入层与输出层.
int numLayers;
//每层的结点数(本数组长度即为层数), 如 {5, 8, 6, 2} 表示输入层5个节点,
//两个隐藏层分别为8个和6个,输出层为2个(可以做2分类)
int[] layerNumNodes;
//神经网络各层节点(对应的临时值)
//对于前面的例子, layerNodes.length = 4, layerNodes[0].length = 5
public double[][] layerNodes;
// 神经网络各节点误差, 与 layerNodes 维度一致
public double[][] layerNodesErr;
// 网络各边权重. 第 i 层节点 与第 i + 1 层之间有边,所以例中 i = {0, 1, 2}, edgeWeights.length = 3;
// edgeWeights[0].length = 5, edgeWeights[0][0].length = 8
public double[][][] edgeWeights;
// 各边权重变化量
public double[][][] edgeWeightsDelta;
// 动量系数, 模拟惯性
public double mobp;
// 学习系数
public double rate;
从代码看出,网络可以用二维、三维数组表示. 基础的表示使我们能够清晰观察内部机制.
3.2 前馈
public double[] computeOut(double[] paraIn) {
// 初始化输入层. paraIn为一条输入数据 (一个训练对象)
for (int i = 0; i < layerNodes[0].length; i++) {
layerNodes[0][i] = paraIn[i];
}// Of for i
// 逐层计算节点值
for (int l = 1; l < numLayers; l++) {
for (int j = 0; j < layerNodes[l].length; j++) {
// 初始化为偏移量, 因为它的输入为 +1
// l - 1表示边的层号
//layerNodes[l - 1].length 表示上一层的节点个数, 由于是偏移量,它在上一层没有节点,是附加的节点, 其取值为1
// j表示本层当前需要赋值的节点.
double z = edgeWeights[l - 1][layerNodes[l - 1].length][j];
// 计算加权和
for (int i = 0; i < layerNodes[l - 1].length; i++) {
// l - 1表示边的层号, i表示上一层节点号, j表示本层节点号
z += edgeWeights[l - 1][i][j] * layerNodes[l - 1][i];
}// Of for i
// Sigmoid激活函数, 代码和公式一样简单.
layerNodes[l][j] = 1 / (1 + Math.exp(-z));
}// Of for j
}// Of for l
return layerNodes[numLayers - 1];
}// Of computeOut
通过3层循环对 layerNodes 赋值,就完成了1次前馈。
3.3 反向传播
public void updateWeight(double[] paraTarget) {
// Step 1. 初始化输出层误差
int l = numLayers - 1;
for (int j = 0; j < layerNodesErr[l].length; j++) {
//前面一半是对 sigmoid 求导, 后面一半是误差 (带符号)
layerNodesErr[l][j] = layerNodes[l][j] * (1 - layerNodes[l][j])
* (paraTarget[j] - layerNodes[l][j]);
}// Of for j
// Step 2. 逐层反馈, l == 0时也需要计算
while (l > 0) {
l--;
// 第l层, 逐个节点计算
for (int j = 0; j < layerNumNodes[l]; j++) {
double z = 0.0;
// 针对下一层的每个节点
for (int i = 0; i < layerNumNodes[l + 1]; i++) {
if (l > 0) {
z += layerNodesErr[l + 1][i] * edgeWeights[l][j][i];
}// Of if
// 隐含层动量调整. mobp 表示对上一个对象训练时 delta 的怀念
// layerNodes[l][j] 是由加权和式子求偏导而得到,表示丢锅的程度
edgeWeightsDelta[l][j][i] = mobp
* edgeWeightsDelta[l][j][i] + rate
* layerNodesErr[l + 1][i] * layerNodes[l][j];
// 隐含层权重调整
edgeWeights[l][j][i] += edgeWeightsDelta[l][j][i];
if (j == layerNumNodes[l] - 1) {
// 截距动量调整, 对平常节点同理, 但需要单独计算
edgeWeightsDelta[l][j + 1][i] = mobp
* edgeWeightsDelta[l][j + 1][i] + rate
* layerNodesErr[l + 1][i];
// 截距权重调整
edgeWeights[l][j + 1][i] += edgeWeightsDelta[l][j + 1][i];
}// Of if
}// Of for i
// 记录误差. 又要乘以 sigmoid 函数的导数, 为下一次后向传播作准备.
layerNodesErr[l][j] = layerNodes[l][j] * (1 - layerNodes[l][j])
* z;
}// Of for j
}// Of while
}// Of updateWeight
反向传播的关键点包括:
- sigmoid 函数的求导. 如果激活函数换了, 相应代码进行变化即可.
- 加权和函数的求导. 估计一般的网络不需要变. 后面的 CNN 之类再说吧.
- 动量系数. 这个已经属于高阶功能了.
感慨一下:基础代码又简单又有效。
4. 小结
- 每层都有针对偏移量的 + 1 +1 +1,参见技术1.2.
- 在实际应用中,神经网络的层数、每层的节点个数、每个节点使用的激活函数,都对其性能产生重要影响。所谓的“调参师”,就是干这些活儿。
- 在本文所述的几个技术上进行改动,都可以做出新的工作。