Ink One

Python 搭建 BP 神经网络

Python-NumPy 搭建 BP 神经网络模块。

BP (Back Propagation) 神经网络是一种用残差反向传播算法训练的多层前馈神经网络。BP网络能学习和存贮大量的输入-输出模式映射关系,而无需已知描述这种映射关系的数学方程。它的学习规则是使用梯度下降法,通过反向传播来不断调整网络的权值和阈值,使网络的损失函数最小。BP神经网络模型拓扑结构包括输入层 (input layer) 、隐藏层 (hidden layer) 和输出层 (output layer) 。


人工神经元模型

神经网络中的基本运算单元为人工神经元。其输出为
$$h_w(x)=f(\sum_{i=0}^nw_ix_i)$$
其中,$x_0=1$ 为偏置 (bias) 单元;$f(\cdot)$ 为激励函数。

常用的激励函数有 $sigmoid$ 和 $tanh$。



$$\sigma(x)=\frac{1}{1+\mathrm{e}^{-x}}\in(0,1)$$
$$\tanh(x)=\frac{\mathrm{e}^x-\mathrm{e}^{-x}}{\mathrm{e}^x+\mathrm{e}^{-x}}\in(-1,1)$$

两个函数之间存在线性关系:
$$\tanh(x)=2\sigma(2x)-1$$

这两个函数的一个重要特性是它们都是可微分的,这使得网络能够应用梯度下降算法来进行训练。它们的一阶导数分别为
$$\frac{\mathrm{d}\sigma(x)}{\mathrm{d}x}=\sigma(x)(1-\sigma(x))$$
$$\frac{\mathrm{d}\tanh(x)}{\mathrm{d}x}=1-\tanh(x)^2$$

神经网络类

定义神经网络类 $nn$ 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import numpy as np
## nn (neural network) 类
class nn():
# 类构造函数,输入参数为网络结构
def __init__(self, nsize):
self.nsize = nsize # 网络结构
self.layers = len(nsize) # 网络层数
self.a = list() # 每层神经元的输出
self.weight = list() # 网络权重
self.d = list() # 每层神经元的残差
self.dW = list() # 权重的偏导数
self.vW = list() # 权重的更新值
self.loss = None # 损失函数
for i in range(self.layers):
if i != self.layers - 1 :
shape = (nsize[i+1], nsize[i]+1)
self.weight.append(np.random.uniform(-1, 1, shape)*np.sqrt(6./(nsize[i]+nsize[i+1])))
self.vW.append(np.zeros(shape))
self.dW.append(None)
self.a.append(None)
self.d.append(None)

前向传播

设$i$、$j$分别为网络中第$l$和第$l+1$层的单元,第$l$层的单元数为$N_l$。将单元$j$的输入记为$u_j$,输出记为$a_j$,从单元$i$到单元$j$连接的权重记为$w_{ij}$,则有
$$u_j=\sum_{i=0}^{N_l}w_{ij}a_i$$

对于隐藏层单元,输出为输入的激励:
$$a_j=f_j(u_j)$$

对于$K$类分类问题$(K>2)$,设置$K$个输出单元,并且采用$softmax$函数归一化得到样本属于第k类的概率:
$$p(C_k)=y_k=a_k=\frac{e^{u_k}}{\sum_{k’=1}^Ke^{u_{k’}}}$$

目标向量$z$是$K$维向量,采用0,1标记所属类别。如对于$K=5$,若正确的类为$C2$,则$z=(0,1,0,0,0)$。目标概率可表达为
$$p(\mathbf{z}|\mathbf{x})=\prod\
{k=1}^Ky_k{z_k}$$

采用神经网络进行分类时,将样本指派给概率最大的类。
定义损失函数为目标概率的负对数:
$$L(\mathbf{y},\mathbf{z}) = -\log p(\mathbf{z}|\mathbf{x})=-\sum_{k=1}^Kz_k\ln y_k$$

$nn$类前向传播函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class nn():
# 前向传播,输入参数为样本特征
def forward(self, x):
m = np.size(x, 0) # batch大小
# 加入偏置单元(bias unit)
self.a[0] = np.hstack((np.ones([m, 1]), x))
for i in range(1, self.layers-1):
# 激活函数采用 tanh
a = np.tanh(np.dot(self.a[i-1], self.weight[i-1].T))
# 加入偏置单元(bias unit)
self.a[i] = np.hstack((np.ones([m, 1]), a))
# 输出层 softmax
a = np.dot(self.a[-2], self.weight[self.layers-2].T)
mx = np.max(a, 1)
b = a - mx.reshape(mx.size, 1)
s = np.sum(np.exp(b), 1)
self.a[-1] = np.exp(b)/s.reshape(s.size, 1)
return self.a[-1]

后向传播

后向传播算法主要应用了偏导的链式法则。首先需要计算损失函数关于输出的偏导,对于$K$类分类问题$(K>2)$
$$\frac{\partial L(\mathbf{y},\mathbf{z})}{\partial y_k} = -\frac{z_k}{y_k}$$
$$\frac{\partial y_{k’}}{\partial u_k}=y_k(\delta_{kk’}-y_{k’})$$

则根据链式法则,有
$$\frac{\partial L(\mathbf{y},\mathbf{z})}{\partial u_k} = \sum_{k’=1}^K\frac{\partial L(\mathbf{y},\mathbf{z})}{\partial y_{k’}}\cdot\frac{\partial y_{k’}}{\partial u_k} = \sum_{k’=1}^K-\frac{z_k}{y_{k’}}\cdot y_k(\delta_{kk’}-y_{k’}) = y_k-z_k$$

定义损失函数关于网络中单元$j$输入的偏导:
$$\delta_j\overset{\mathrm{def}}{=}\frac{\partial L(\mathbf{y},\mathbf{z})}{\partial u_j}$$

设$i$、$j$分别为网络中第$l$和第$l+1$层的单元,第$l$层的单元数为$N_l$。对于隐藏层单元$i$,其$\delta$值可由后一层递归计算得到:
$$\delta_i = \frac{\partial L(\mathbf{y},\mathbf{z})}{\partial a_i}\cdot\frac{\partial a_i}{\partial u_i} = \frac{\partial a_i}{\partial u_i}\sum_{j=1}^{H_{l+1}}\frac{\partial L(\mathbf{y},\mathbf{z})}{\partial u_j}\cdot\frac{\partial u_j}{\partial a_i} = f_i’(u_i)\sum_{j=1}^{H_{l+1}}\delta_jw_{ij}$$

当计算得到所有隐藏层单元的$\delta$值后,可计算损失函数关于网络中权重的偏导:
$$\frac{\partial L(\mathbf{y},\mathbf{z})}{\partial w_{ij}} = \frac{\partial L(\mathbf{y},\mathbf{z})}{\partial u_j}\cdot\frac{\partial u_j}{\partial w_{ij}} = \delta_ja_i$$

$nn$类后向传播函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class nn():
# 后向传播,输入参数为样本期望输出
def backward(self, y):
m = self.a[0].shape[0] # batch大小
self.loss = - np.sum(y * np.log(self.a[-1])) / m
# 输出层残差
self.d[-1] = self.a[-1] - y
# 隐藏层残差
for i in range(self.layers-2, 0, -1):
d_a = 1 - np.power(self.a[i], 2)
if i == self.layers - 2 :
self.d[i] = np.dot(self.d[i+1], self.weight[i]) * d_a
else:
self.d[i] = np.dot(self.d[i+1][:, 1:self.d[i+1].shape[1]], self.weight[i]) * d_a
# 计算网络权重的梯度
for i in range(self.layers - 1):
if i == self.layers - 2:
self.dW[i] = np.dot(self.d[i+1].T, self.a[i]) / m
else:
self.dW[i] = np.dot(self.d[i+1][:, 1:self.d[i+1].shape[1]].T, self.a[i]) / m

网络权重更新

采用梯度下降算法对网络的权重进行更新:
$$\Delta w_{ij} := -\alpha\frac{\partial L}{\partial w_{ij}}$$
$$w_{ij} := w_{ij} + \Delta w_{ij}$$

为了加速算法收敛,可引入$momentum$项。$momentum$项模拟物体运动时的惯性,更新权重时在一定程度上保留之前更新的方向,同时利用当前的梯度调整更新方向。
$$\Delta w_{ij} := m\Delta w_{ij}-\alpha\frac{\partial L}{\partial w_{ij}}$$

$nn$类网络权重更新函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class nn():
# 网络参数更新,输入参数为训练参数字典
def updateParam(self, opt):
# 若 opt 中未定义 lambda 和 momentum,则设置为0
key_list = ["lambda", "momentum"]
for key in key_list:
if key not in opt.keys():
opt[key] = 0
m = self.a[0].shape[0] # batch大小
# 更新网络权重
for i in range(self.layers-1):
dW = self.dW[i]
# L2范数惩罚项,防止过拟合
if opt["lambda"] > 0:
weight = np.hstack((np.zeros((self.weight[i].shape[0], 1)), self.weight[i][:, 1:self.weight[i].shape[1]]))
dW = dW + opt["lambda"] * weight / m
dW = - opt["lr"] * dW
# momentum项,加快收敛
if opt["momentum"] > 0:
self.vW[i] = opt["momentum"] * self.vW[i] + dW
dW = self.vW[i]
self.weight[i] = self.weight[i] + dW

网络训练和测试

为了更好地管理数据集,定义数据集类$dataset$类,并定义成员函数 next_batch 用于批量取出数据集中的样本。

1
2
3
4
5
6
7
8
9
10
11
# dataset 类
class dataset():
# 构造函数,输入参数为样本的特征(和标签)
def __init__(self, x, label):
self.x = x # 样本特征
self.y = ... # 样本期望输出
# 批量取出样本,输入参数为返回的样本数
def next_batch(self, batchsize):
...
return batch_x, batch_y

定义$nn$类的训练和测试函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class nn():
# 预测(分类),返回类别
def predict(self, x):
prob = self.forward(x)
return np.argmax(prob, 1)
# 网络训练,输入参数data为数据集,opt为训练参数字典
def train(self, data, opt):
n = data.size * opt["epochs"] // opt["batchsize"]
for i in range(n):
batch_x, batch_y = data.next_batch(opt["batchsize"])
self.forward(batch_x)
self.backward(batch_y)
self.updateParam(opt)
# 测试,输入参数为数据集,返回准确率
def test(self, data):
labels = self.predict(data.x)
expected = np.argmax(data.y, 1)
# 比较预测的类别和实际所属类别
right = np.sum(labels == expected)
accuracy = float(right) / data.size
return accuracy