用Python手撕ANN

有幸做了几天面试官,对于我个人来说也是一个不小的成长,同时也发现了许多自身的问题。万丈高楼平地起,有很多东西我还是只停留在会用的程度,内部的实现细节早就忘得一干二净了。因此我准备新开一个专栏(A Beginner’s Guide To Neural Network),用于介绍用Python手撕神经网络,主要目的是加深自己对这些神经网络实现细节的理解,该专栏的代码出自斋藤康毅的两本书《深度学习入门:基于Python的理论与实现》和《深度学习进阶:自然语言处理》。

神经网络

从感知机到神经网络

神经网络是由感知机发展而来的,感知机是用于处理线性可分问题的线性处理单元,用公式表示为

y=wx+by=wx+b

这是一个很简单的公式,其中w被称为权重,b被称为偏置,一个感知机只能处理线性问题,但是感知机通过叠加层能够进行非线性的表示,多层的感知机虽然能够来解决非线性问题,但是权重和偏置还是需要人为的去设置,有没有办法能够自动学习这些权重和偏置呢?答案自然是通过神经网络,神经网络的一个重要性质是它可以自动地从数据中学习到合适的权重参数。

神经网络由输入层中间层(隐藏层)输出层构成,如下图所示,神经网络中的圆圈表示神经元,输入层的神经元将其和各自的权重相乘后,传送至下一个神经元。在下一个神经元中,计算这些加权信号的总和。

激活函数

那么如何把输入信号的总和转换为输出信号呢?这里就需要激活函数登场了,如激活一词所示,激活函数的作用在于决定如何来激活输入信号的总和。常用的激活函数包括sigmoid函数ReLU函数等。

sigmoid函数

神经网络中经常使用的激活函数就是sigmoid函数,sigmoid函数的公式为

h(x)=11+exh(x)=\frac{1}{1+e^{-x}}

利用Python的numpy库可以很容易的实现,代码为

import numpy as np
def sigmoid(x):
return 1 / (1 + np.exp(-x))

ReLU函数

ReLU 函数是另一个经常使用的激活函数,当x大于0时取其本身,当x小于等于0时取0,ReLU 函数的实现也很简单,代码为

def relu(x):
return np.maximum(0, x)

输出层函数

神经网络可以用在分类问题和回归问题上,不过需要根据情况改变输出层的激活函数。一般而言,回归问题用恒等函数,分类问题用 softmax 函数,恒等函数会将输入按原样输出,对于输入的信息,不加以任何改动地直接输出。因此,在输出层使用恒等函数时,输入信号会原封不动地被输出,因此重点介绍下 softmax 函数的实现。

softmax 函数

分类问题中使用的 softmax 函数可以用下面的公式来表示

yk=exki=1nexiy_k=\frac{e^{-x_k}}{\sum_{i=1}^{n}e^{-x_i}}

表示假设输出层共有 n 个神经元,计算第 k 个神经元的输出yky_k,代码实现为

def softmax(a):
c = np.max(a)
exp_a = np.exp(a - c) # 溢出对策
sum_exp_a = np.sum(exp_a)
y = exp_a / sum_exp_a

return y

其中,需要在进行 softmax 的指数函数的运算时,减去某个常数值来防止溢出。

神经网络的学习

神经网络的特征就是可以从数据中学习,所谓学习就是可以由数据自行决定权重参数的值,在实际的神经网络中,参数的数量成千上万,在层数更深的深度学习中,参数的数量甚至可以上亿,想要人工决定这些参数的值是不可能的,因此就需要神经网络具有学习的能力。

损失函数

神经网络的学习需要通过某个指标表示现在的状态,并以这个指标为基准,寻找最优的权重参数,这个指标就是损失函数,常见的损失函数时均方误差和交叉熵误差。

均方误差

可以用作损失函数的函数有很多,其中最有名的是均方误差(mean squared error),均方误差的公式为

E=12k(yktk)2E=\frac{1}{2}\sum_k(y_k-t_k)^2

均方误差的实现代码为

def mean_squared_error(y, t):
return 0.5 * np.sum((y-t)**2)

交叉熵误差

除了均方误差之外,交叉熵误差(cross entropy error)也经常被用作损失函数,交叉熵误差的公式为

E=ktklogykE=-\sum_kt_klogy_k

其中,log 表示以e为底数的自然对数,yky_k是神经网络的输出,tkt_k是正确解的标签(比如二分类的0或1),交叉熵误差的实现代码为

def cross_entropy_error(y, t):
delta = 1e-7
return -np.sum(t * np.log(y + delta))

函数内部在计算 np.log 时,加上了一个微小值 delta,这是因为当出现np.log(0)时,np.log(0)会变为负无限大,需要添加一个微小值来防止负无限大的发生。

梯度

神经网络的主要任务是在学习时寻找最优参数(权重和偏置),最优参数是指使损失函数取最小值的参数,一般来说,损失函数很复杂,参数空间也很庞大,我们不知道它在何处能够取得最小值。

什么是梯度

为了寻求最小值,我们需要借助梯度来帮忙,梯度表示的使各点处的函数值减小最多的方向,沿着梯度的方向走能够最大限度地减小函数的值

比如,函数f(x0,x1)=x02+x12f(x_0,x_1)=x_0^2+x_1^2的图像如下所示

看图像可以看到它的最小值在最中心处,它的梯度图如下所示,可以看出梯度的方向指向最小值的方向

偏导是指参数在某一时刻细微的变化,即瞬间的变化量,比如求x0x_0的偏导可以定义为

fdx0=limh>0f(x0+h)f(x0)h\frac{\partial f}{dx_0}=lim_{h->0}\frac{f(x_0+h)-f(x_0)}{h}

上面的式子叫做数值微分(numerical differentiation),又称为前向差分,但是计算会含有误差,为了减小这个误差,可以采用计算f在(x0+H)(x_0+H)(x0h)(x_0-h)之间的差分,也称为中心差分,中心差分可用如下方式得到

def numerical_diff(f, x):
h = 1e-4 # 0.0001 表示一个微小的变化
return (f(x+h) - f(x-h)) / (2*h)

求梯度就是求各参数的偏导,还以函数f(x0,x1)=x02+x12f(x_0,x_1)=x_0^2+x_1^2为例,用公式来表示梯度为

x0=x0fx0x1=x1fx1x_0=x_0-\frac{\partial f}{\partial x_0} \\ x_1=x_1-\frac{\partial f}{\partial x_1}

求梯度可以由下面这段代码来实现

def numerical_gradient(f, x):
h = 1e-4 # 0.0001
grad = np.zeros_like(x) # 生成和x形状相同的数组

for idx in range(x.size):
tmp_val = x[idx]
# f(x+h)的计算
x[idx] = tmp_val + h
fxh1 = f(x)

# f(x-h)的计算
x[idx] = tmp_val - h
fxh2 = f(x)

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

return grad

函数的取值从当前位置沿着梯度方向前进一定距离,然后在新的地方重新求梯度,再沿着新梯度方向前进,如此反复,不断地沿梯度方向前进。像这样,通过不断地沿梯度方向前进,逐渐减小函数值的过程就是梯度法(gradient method),梯度法和上面的公式差不多,只是多了一个η\eta

x0=x0ηfx0x1=x1ηfx1x_0=x_0-\eta\frac{\partial f}{\partial x_0} \\ x_1=x_1-\eta\frac{\partial f}{\partial x_1}

其中,η\eta在神经网络中被称为学习率,学习率决定在一次学习中,应该学习多少,以及在多大程度上更新参数,梯度法可以像下面这样来实现

def gradient_descent(f, init_x, lr=0.01, step_num=100):
x = init_x

for i in range(step_num):
grad = numerical_gradient(f, x)
x -= lr * grad

return x

利用梯度法的2层神经网络

有了前面的sigmoid函数、softmax函数、交叉熵损失函数、梯度法我们就可以来构建一个简单的2层神经网络,代码如下

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)

def predict(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

# x:输入数据, t:监督数据
def loss(self, x, t): # 计算损失函数的值
y = self.predict(x)

return cross_entropy_error(y, t)

def accuracy(self, x, t): # 计算识别精度
y = self.predict(x)
y = np.argmax(y, axis=1)
t = np.argmax(t, axis=1)

accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy

# x:输入数据, t:监督数据
def numerical_gradient(self, x, t): # 计算权重参数的梯度
loss_W = lambda W: self.loss(x, t)

grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])

return grads

TwolayerNet类中出现的成员变量做一下解释

变量 说明
params 保存神经网络的参数的字典型变量(实例变量)。 params['W1'] 是第 1 层的权重,params['b1'] 是第 1 层的偏置。 params['W2'] 是第 2 层的权重,params['b2'] 是第 2 层的偏置
grads 保存梯度的字典型变量(numerical_gradient() 方法的返回值)。 grads['W1'] 是第 1 层权重的梯度,grads['b1'] 是第 1 层偏置的梯度。 grads['W2'] 是第 2 层权重的梯度,grads['b2'] 是第 2 层偏置的梯度

由于数据是随机选择,因此又称为随机梯度下降法(stochastic gradient descent),随机梯度下降仅以当前样本点进行最小值求解,通常无法达到真正局部最优解,但可以比较接近,在样本量庞大时就显得收敛速度比较快,因此被广泛使用,上面这个2层神经网络的使用方法也比较简单

net = TwoLayerNet(input_size=784, hidden_size=100, output_size=10)
x = np.random.rand(100, 784) # 伪输入数据(100笔)
t = np.random.rand(100, 10) # 伪正确解标签(100笔)
grads = net.numerical_gradient(x, t) # 计算梯度

执行完成后,grads中会学习到最优的权重值。

误差反向传播

上一节实现的2层神经网络通过数值微分计算了神经网络的权重参数的梯度,数值微分虽然简单,也容易实现,但缺点是计算上比较费时间,因此提出了一个能够高效计算权重参数梯度的方法——误差反向传播法

链式法则

正常的计算结果的传递顺序是从左到右,而反向传播将局部导数则从右到左进行传递,传递这个局部导数的原理,是基于链式法则(chain rule),链式法则是用来计算复合函数的偏导数,如果某个函数由复合函数表示,则该复合函数的导数可以用构成复合函数的各个函数的导数的乘积表示,比如函数z=(x+y)2z=(x+y)^2是由式子z=t2z=t^2t=x+yt=x+y两个式子构成的,则对求x得偏导则为

zzzttx=zttx=zx\frac{\partial z}{\partial z}\frac{\partial z}{\partial t}\frac{\partial t}{\partial x}=\frac{\partial z}{\partial t}\frac{\partial t}{\partial x}=\frac{\partial z}{\partial x}

根据链式法则,z关于x的偏导数可以从最右边一点一点的局部相乘得到。

反向传播

基于链式法则,实现乘法层、加法层、激活函数层和输出层的反向传播,在实现反向传播时需要注意参数的翻转,层的实现中有两个共通的方法(接口)forward()`和 backward()。forward() 对应正向传播,backward() 对应反向传播。

乘法层

乘法层作为 MulLayer 类,其实现过程如下所示

class MulLayer:
def __init__(self):
self.x = None
self.y = None

def forward(self, x, y):
self.x = x
self.y = y
out = x * y

return out

def backward(self, dout):
dx = dout * self.y # 翻转x和y
dy = dout * self.x

return dx, dy

加法层

加法层作为 AddLayer类,其实现过程如下所示

class AddLayer:
def __init__(self):
pass

def forward(self, x, y):
out = x + y
return out

def backward(self, dout):
dx = dout * 1
dy = dout * 1
return dx, dy

激活函数层

ReLU层

ReLU层,如果正向传播时的输入 x 大于 0,则反向传播会将上游的值原封不动地传给下游。反过来,如果正向传播时的 x 小于等于 0,则反向传播中传给下游的信号将停在此处,实现过程如下所示

class Relu:
def __init__(self):
self.mask = None

def forward(self, x):
self.mask = (x <= 0)
out = x.copy()
out[self.mask] = 0

return out

def backward(self, dout):
dout[self.mask] = 0
dx = dout

return dx

Sigmoid 层

回顾一下sigmoid函数的表达式

y=11+exy=\frac{1}{1+e^{-x}}

反向传播的计算方法可以由下面的计算图求得

进一步整理可以得到

因此,sigmoid层的实现过程如下所示

class Sigmoid:
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):
dx = dout * (1.0 - self.out) * self.out

return dx

输出层

神经网络的正向传播中,为了计算加权信号的总和,使用了矩阵的乘积运算,即numpy的.dot()操作,神经网络的正向传播中进行的矩阵的乘积运算在几何学领域被称为仿射变换,因此,这里将进行仿射变换的处理实现为Affine 层。

Affine 层

在计算Affine 层的反向传播时要注意矩阵维度的变化,Affine 层的反向传播可由下面的计算图来表示

因此,Affine 层的实现过程如下所示

class Affine:
def __init__(self, W, b):
self.W = W
self.b = b
self.x = None
self.dW = None
self.db = None

def forward(self, x):
self.x = x
out = np.dot(x, self.W) + self.b

return out

def backward(self, dout):
dx = np.dot(dout, self.W.T)
self.dW = np.dot(self.x.T, dout)
self.db = np.sum(dout, axis=0)

return dx

Softmax-with-Loss 层

最后介绍一下输出层的 softmax 函数。前面我们提到过,softmax 函数会将输入值正规化之后再输出,举一个MINST(手写数字识别)任务的例子,Softmax 层的输出如下图所示

其中,因为手写数字识别要进行 10 类分类,所以向softmax 层的输入有 10 个,输入图像通过 Affine 层和 ReLU 层进行转换,10 个输入通过 softmax 层进行正规化。在这个例子中,“0”的得分是 5.3,这个值经过 softmax 层转换为 0.008(0.8%);“2”的得分是 10.1,被转换为 0.991(99.1%)

下面来实现 softmax 层。考虑到这里也包含作为损失函数的交叉熵误差(cross entropy error),所以称为Softmax-with-Loss 层。Softmax-with-Loss 层(Softmax 函数和交叉熵误差)的计算图比较复杂,如下图所示

反向传播的计算过程就省略了, Softmax-with-Loss 层的实现过程如下所示

class SoftmaxWithLoss:
def __init__(self):
self.loss = None # 损失
self.y = None # softmax的输出
self.t = None # 监督数据(one-hot vector)

def forward(self, x, t):
self.t = t
self.y = softmax(x)
self.loss = cross_entropy_error(self.y, self.t)

return self.loss

def backward(self, dout=1):
batch_size = self.t.shape[0]
dx = (self.y - self.t) / batch_size

return dx

神经网络的全貌

神经网络就像搭建乐高积木一样,把之前实现的层一层一层的堆叠即可实现神经网络的功能,下面就实现添加了反向传播的2层神经网络,代码如下

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)

# 生成层
self.layers = OrderedDict()
self.layers['Affine1'] = \
Affine(self.params['W1'], self.params['b1'])
self.layers['Relu1'] = Relu()
self.layers['Affine2'] = \
Affine(self.params['W2'], self.params['b2'])

self.lastLayer = SoftmaxWithLoss()

def predict(self, x):
for layer in self.layers.values():
x = layer.forward(x)

return x

# x:输入数据, t:监督数据
def loss(self, x, t):
y = self.predict(x)
return self.lastLayer.forward(y, t)

def accuracy(self, x, t):
y = self.predict(x)
y = np.argmax(y, axis=1)
if t.ndim != 1 : t = np.argmax(t, axis=1)
accuracy = np.sum(y == t) / float(x.shape[0])
return accuracy

# x:输入数据, t:监督数据
def numerical_gradient(self, x, t):
loss_W = lambda W: self.loss(x, t)

grads = {}
grads['W1'] = numerical_gradient(loss_W, self.params['W1'])
grads['b1'] = numerical_gradient(loss_W, self.params['b1'])
grads['W2'] = numerical_gradient(loss_W, self.params['W2'])
grads['b2'] = numerical_gradient(loss_W, self.params['b2'])

return grads

def gradient(self, x, t):
# forward
self.loss(x, t)

# backward
dout = 1
dout = self.lastLayer.backward(dout)

layers = list(self.layers.values())
layers.reverse()
for layer in layers:
dout = layer.backward(dout)

# 设定
grads = {}
grads['W1'] = self.layers['Affine1'].dW
grads['b1'] = self.layers['Affine1'].db
grads['W2'] = self.layers['Affine2'].dW
grads['b2'] = self.layers['Affine2'].db

return grads

TwoLayerNet类的实例变量的说明如下

实例变量 说明
params 保存神经网络的参数的字典型变量。 params['W1'] 是第 1 层的权重,params['b1'] 是第 1 层的偏置。 params['W2'] 是第 2 层的权重,params['b2'] 是第 2 层的偏置
layers 保存神经网络的层的有序字典型变量。 以 layers['Affine1']layers['ReLu1']layers['Affine2'] 的形式,通过有序字典保存各个层
lastLayer 神经网络的最后一层。本例中为 SoftmaxWithLoss

在上面的代码中,OrderedDict 表示有序字典,有序是指它可以记住向字典里添加元素的顺序。因此,神经网络的正向传播只需按照添加元素的顺序调用各层的 forward()`方法就可以完成处理,而反向传播只需要按照相反的顺序调用各层即可。因为 Affine 层和 ReLU 层的内部会正确处理正向传播和反向传播,所以这里要做的事情仅仅是以正确的顺序连接各层,再按顺序(或者逆序)调用各层。

以MNIST 这个测试数据集为例,来测试一下我们的神经网络,MNIST 数据集包含了从0到9的手写字符图像,如下图是数字5的部分手写字符图像,你需要做的任务是给你任意一张手写字符图像,你的神经网络能准确的的对这个手写字符图像进行分类,输出其表示的数字。

在MNIST数据集上训练的的代码如下,注意该部分代码使用了批处理,能够缩短训练的时间

import numpy as np
from dataset.mnist import load_mnist

# 读入数据
(x_train, t_train), (x_test, t_test) = \
load_mnist(normalize=True, one_hot_label=True)

network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10)

iters_num = 10000
train_size = x_train.shape[0]
batch_size = 100
learning_rate = 0.1
train_loss_list = []
train_acc_list = []
test_acc_list = []

iter_per_epoch = max(train_size / batch_size, 1)

for i in range(iters_num):
batch_mask = np.random.choice(train_size, batch_size)
x_batch = x_train[batch_mask]
t_batch = t_train[batch_mask]

# 通过误差反向传播法求梯度
grad = network.gradient(x_batch, t_batch)

# 更新
for key in ('W1', 'b1', 'W2', 'b2'):
network.params[key] -= learning_rate * grad[key]

loss = network.loss(x_batch, t_batch)
train_loss_list.append(loss)

if i % iter_per_epoch == 0:
train_acc = network.accuracy(x_train, t_train)
test_acc = network.accuracy(x_test, t_test)
train_acc_list.append(train_acc)
test_acc_list.append(test_acc)
print(train_acc, test_acc)
Author: Hongyi Guo
Link: https://guohongyi.com/2020/10/29/用Python手撕ANN/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.