基于 NumPy 实现无框架版本全连接神经网络
Aidan Engineer

最近又扎进了深度学习的大坑里边,开始了一段新的学习过程。深度学习的东西相较于之前做后端研发而言代码较为简单,重在理解其中的算法逻辑和公式推导,因为有几个深度学习框架的存在,使得做出一个可用的算法模型变得异常简单,所以想着尝试用原生的方式实现目前比较主流的一些算法,第一个就从最初的全连接开始

因为工作相关,我这里以图像分类为例,用的是 mnist 手写数字识别的数据,至于数据的获取这里就不写了,只介绍算法相关

实现神经网络

引入权重

神经网络的本质就是将无法进行明确逻辑梳理的功能实现出来,实现的过程就是用大量符合目标结果的数据喂给机器学习,从而让程序拟合出解决这一类问题的能力

既然是学习,那个我们让输入的数据怎么计算,最后才能符合最终的结果呢,这就涉及到了权重,权重与神经元是从生物学的角度换算得来的,对于一张图片(这里以图像为例,实际包含任何可输入的东西)人看到之后会对其主体作出判断,然后是色彩,细节,这些不同的判断与人们对这张图片最后形成的印象所产生的影响是不同的,由此就有了权重的划分,至于哪些判断是重要的,重要和不重要的权重又应该设置为多少那就是机器需要学习的了

1
2
3
4
5
6
7
class TwoLayerNet:
def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01):
self.params = {}
self.params["W1"] = weight_init_std * np.random.randn(input_size, hidden_size)
self.params["b1"] = np.zeros(hidden_size)
self.params["W2"] = weight_init_std * np.random.randn(hidden_size, output_size)
self.params["b2"] = np.zeros(output_size)

依照代码逐行分析为什么要这么写

  1. 首先看参数,input_size 对应图片输入量,任何图片都可以根据其像素,色域等分成一个多维的数据,为了方便处理,可以将数据压缩成一维,而这个一维数组的长度就是该张图片所有的信息量,也就是这里的 size
    hidden_size 是第二层的大小,对输入的信息进行的运算之后会将其传递给下一层的神经元
    output_size 是最终输入层的大小,因为这里是对数字的识别,那么输出的结果就可以看作数组 090\backsim9 的集合
  2. params 代表所有的权重信息,当结合权重运算的结果大于某个值时才有其发挥的意义,这个值姑且称为 θ\theta,也就是下方的公式

y={0(w1x1+w2x2)θ1(w1x1+w2x2)>θ    y={0(b+w1x1+w2x2)01(b+w1x1+w2x2)>0y = \begin{cases} 0 (w_{1}x_{1} + w_{2}x_{2}) \eqslantless \theta \\ 1 (w_{1}x_{1} + w_{2}x_{2}) > \theta \end{cases} \implies y = \begin{cases} 0 (b + w_{1}x_{1} + w_{2}x_{2}) \eqslantless 0 \\ 1 (b + w_{1}x_{1} + w_{2}x_{2}) > 0 \end{cases}

激活函数

我们上方的公式已经可以支撑两层神经网络的训练了,但是对于其输出只有 0, 1 这显然无法起到对数据进行信息量传递的效果,或者说有效果,但是很少,所以需要对其进行其他处理,使其输出一些有价值且信息跳跃不会过大的值,这就引入了激活函数的概念,这里我是用最常见的 sigmoid(),同样输出层函数选用分类场景下最常见的 softmax(),其对应公式如下:

Sigmoid(x)=σ(x)=11+exSoftmax(xi)=exij=1nexj\text{Sigmoid}(x) = \sigma(x) = \frac{1}{1 + e^{-x}} \qquad \text{Softmax}(x_i) = \frac{e^{x_i}}{\sum_{j=1}^n e^{x_j}}

有了公式,代码就实现起来简单多了:

1
2
3
4
5
6
def sigmoid(x):
return 1 / (1 + np.exp(-x))

def softmax(x):
x = x - np.max(x)
return np.exp(x) / np.sum(np.exp(x))

上边的 softmax() 函数中有一行在公式之前的计算,用数组 x 的每个元素减去了数组中的最大值,这其实是为了方式数组中各元素过大导致进行 exe^{x} 运算时结果超过计算机有效位数(4 或 8 字节)

同时考虑到实际学习中会进行批量数据的导入,下方把批量计算的代码也实现出来,批量无非就是对每一行单独进行计算

1
2
3
4
5
6
7
8
9
10
11
12
13
def softmax(x):
if x.ndim == 2:
x = x.T
x = x - np.max(x, axis=0)
y = np.exp(x) / np.sum(np.exp(x), axis=0)
return y.T

# if x.ndim == 2:
# x = x - np.max(x, axis=1, keepdims=True)
# return np.exp(x) / np.sum(np.exp(x), axis=1, keepdims=True)

x = x - np.max(x)
return np.exp(x) / np.sum(np.exp(x))

将损失函数与权重计算结合起来,代码如下

1
2
3
4
5
6
7
8
def perdict(self, x):
w1, w2 = self.params["W1"], self.params["W2"]
b1, b2 = self.params["b1"], self.params["b2"]
a1 = np.dot(x, w1) + b1
z1 = sigmoid(a1)
a2 = np.dot(z1, w2) + b2
y = softmax(a2)
return y

至此一个简单的神经网络就实现完成了,为了便于直观感受,我们可以对总量的一个准确度进行输出

1
2
3
4
5
6
def accuracy(self, x, t):
y = self.perdict(x)
y = np.argmax(y, axis=1)
t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy

损失函数

以上只是一个神经网络的实现过程,好像并没有引入自我学习的概念,所谓学习也就是对权重和偏置的一个修改,使其处在一个合适的值,既可以拟合解决所有的学习数据,又具备相当的泛化能力

那么应该对该权重值怎么进行改变呢,改变的首要条件就是得确定什么样的结果是好的,以这个结果为判断依据才有改变的意义,否则随意调整参数没有任何意思,由此又引入了损失函数的概念

相较于准确度,损失函数有几个优点:

  1. 损失函数是连续的,每个结果的输出都可以用损失函数给出一个明确的差异,而准确度只能说明该结果是否正确,对权重变化不敏感
  2. 同样损失函数表达当前结果与正确结果有多大的差异,准确度只能粗暴的将结果都认定为 100%,对于 [0.9,0.1],[0.51,0.49][0.9, 0.1], [0.51, 0.49] 这两组数据,表现的信息和可信度就有明确的差异
  3. 可微分,能够指明参数优化的方向

这里选用交叉熵误差的作为计算本次的损失函数,公式如下:

Cross-Entropy=1Ni=1Nk=1Cyi,klog(y^i,k)\text{Cross-Entropy} = -\frac{1}{N} \sum_{i=1}^N \sum_{k=1}^C y_{i,k} \log(\hat{y}_{i,k})

直接看代码实现,便于理解:

1
2
3
4
5
6
7
8
9
10
def cross_entropy_error(y, t):
if y.ndim == 1:
t = t.reshape(1, t.size)
y = y.reshape(1, y.size)

if t.size == y.size:
t = t.argmax(axis=1)

batch_size = y.shape[0]
return -np.sum(np.log(y[np.arange(batch_size), t] + 1e-7)) / batch_size

几个注意点:

  • 不是批量训练时转换为个数为 11 的二维数组
  • 如果是 one-hot 形式,转换为类别索引(也叫做稀疏标签)
  • 计算时对结果加一个极小值 1e71e-7 防止除数为 00

梯度计算

目前已经有了损失函数的概念,但是损失函数只是告诉当前训练是好是坏,作为学习依据进行使用,那么有了依据,应该怎么调整权重值,这自然而然就引入了导数的概念

对函数 f(x)=y=x2f(x)=y=x^{2} 进行求导,得到 f(x)=dydx=2xf'(x)=\frac{dy}{dx}=2x,其中 x=2x=2 时的值为 44 这里的 44 不仅是看作为结果更是还能看作当 x=2x=2 时其沿着 xx 坐标增大时(导数值为正数为增大,负数为减少)对应的 yy 值变大

想象一下这一个巨简单的神经网络损失函数,其只有一个权重值 xx,那么是否就可以根据导数进行数值的更新,只是对于损失函数而言取负数,这里对导数添加负号即可,每次移动的量尽量小,以免错过最低点,当到达最低点时 yy 的值最小,也就是损失为 00 达到一个绝对高的准确率(这是最简单的实现理解,并不严谨)

将以上函数规模扩大,那么这时计算的所有偏导数值也就是梯度就指明了学习的方向,其导数与梯度公式如下:

f(x)=limh0f(x+h)f(x)hWL=LW=[LW11LW12LW1nLW21LW22LW2nLWm1LWm2LWmn]f'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h} \qquad \nabla_W L = \frac{\partial L}{\partial W} = \begin{bmatrix} \frac{\partial L}{\partial W_{11}} & \frac{\partial L}{\partial W_{12}} & \cdots & \frac{\partial L}{\partial W_{1n}} \\ \frac{\partial L}{\partial W_{21}} & \frac{\partial L}{\partial W_{22}} & \cdots & \frac{\partial L}{\partial W_{2n}} \\ \vdots & \vdots & \ddots & \vdots \\ \frac{\partial L}{\partial W_{m1}} & \frac{\partial L}{\partial W_{m2}} & \cdots & \frac{\partial L}{\partial W_{mn}} \end{bmatrix}

当然我们的权重不可能只有一个,所以这里直接使用梯度,以下为代码实现的经典方式,也就是中心差分法,具体实现逻辑如下:

对输入数组 x 的每个元素进行正向和反向的微小变化(变化量为 h),然后计算这些变化导致的函数值差值,最后将差值除以 2*h 得到近似的梯度值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def _numerical_gradient_no_batch(f, x):
h = 1e-4 # 0.0001
grad = np.zeros_like(x)

for idx in range(x.size):
tmp_val = x[idx]
# 计算正向步长的偏导数
x[idx] = float(tmp_val) + h
fxh1 = f(x)

# 计算反向步长的偏导数
x[idx] = tmp_val - h
fxh2 = f(x)

grad[idx] = (fxh1 - fxh2) / (2 * h)
x[idx] = tmp_val

return grad

注意以上代码只是计算一维数组的方式,多维参数使用遍历调用的方式进行实现

反向传播

其实以上就已经能够进行神经网络的基础训练了,但在实际训练过程中会发现他有些过于缓慢了,主要是在计算每个参数的梯度值时需要进行大量的计算,而随着神经网络层级的加深,其对应的计算量也成指数级增加,由此便引入了反向传播法进行梯度的计算

反向传播可能带来训练上的一些缺陷,比如梯度不准确(梯度消失或爆炸),学习率选择导致训练不稳定或者收敛过慢,但我们这里只关注反向传播的推导过程和具体实现

链式法则

链式法则是反向传播的基础,首先来看其关于复合函数的导数定义:如果某个函数由复合函数表示,那么该复合函数的导数可以由构成复合函数的各函数的导数的乘积进行表示

对于复合函数 z=(x+y)2z=(x+y)^2 可看作:z=t2,t=x+yz=t^2, t=x+y,那么根据复合函数的定义其导数如下:

zx=zttx=2t1=2(x+y)\frac{\partial z}{\partial x}=\frac{\partial z}{\partial t}\frac{\partial t}{\partial x}=2t \cdot 1=2(x+y)

性质很简单,其实就是把 t{\partial t} 互相约掉

反向传播实现

以 sigmoid 函数及其链式的公式作为示例

y=11+exp(x)y=\frac{1}{1+exp(-x)}

xx1-1 进行相乘然后进行 expexp 运算,之后 +1+1 然后 1/1/

然后以反向的方式来看,(反向的起始值表示为 Ly\frac{\partial L}{\partial y}):

  1. // 节点作为局部函数表示为 y=1/xy=1/x,导数解析为 yx=1x2\frac{\partial y}{\partial x}=-\frac{1}{x^2},由于 yy 是已知的,可以简单表示为 yx=y2\frac{\partial y}{\partial x}=-y^2。在反向传播时会将上游的值乘以该导数往前传递,结果变为 Lyy2-\frac{\partial L}{\partial y}y^2
  2. 加法的传递不需要改变内容,结果仍为 Lyy2-\frac{\partial L}{\partial y}y^2
  3. expexp 的导数形式不变,用 exp(x)exp(-x) 乘以上游数据,结果变为 Lyy2exp(x)-\frac{\partial L}{\partial y}y^2exp(-x)
  4. 然后是与 1-1 相乘,反向传播的乘法需要进行反转,所以乘以 1-1,结果变为 Lyy2exp(x)\frac{\partial L}{\partial y}y^2exp(-x)

对该结果进行公式简化:

Lyy2exp(x)=Ly1(1+exp(x))2exp(x)=Ly11+exp(x)exp(x)1+exp(x)=Lyy(1y)\frac{\partial L}{\partial y} y^2 \exp(-x) = \frac{\partial L}{\partial y} \frac{1}{(1 + \exp(-x))^2} \exp(-x) \\ = \frac{\partial L}{\partial y} \frac{1}{1 + \exp(-x)} \frac{\exp(-x)}{1 + \exp(-x)} \\ = \frac{\partial L}{\partial y} y(1 - y)

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class sigmoid_layer:
def __init__(self):
self.out = None

def forward(self, x):
out = 1 / (1 + np.exp(-x))
self.out = out
return out

def backward(self, dout):
if self.out is None:
raise Exception("Please forward first")
dx = dout * (1.0 - self.out) * self.out
return dx

总结

以上就是全连接神经网络的简单实现和基本细节,具体代码可以看我的 github/DL-NumPy

  • 本文标题:基于 NumPy 实现无框架版本全连接神经网络
  • 本文作者:Aidan
  • 创建时间:2024-11-25 00:17:56
  • 本文链接:https://aidanblog.top/native_code-fcnn/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论