一、前置知识
神经网络主要包含两大核心步骤,前向传播获得预测的结果,反向传播根据预测值与真实值的损失计算每个权重和偏置的梯度,然后根据梯度更新参数,以此往复便是训练的过程。
前向传播
其中前向传播比较简单,在这个过程中,信息从输入层流向输出层,每一层的输出都作为下一层的输入,每一层都包括权重 w
和 偏置 b
(非必须),然后使用 σ(wx+b)
方式计算每一层的结果,其中 σ
表示非线性激活函数,例如 sigmod
、tanh
、relu
等,经过层层计算最终输出便是预测值。
其中单层的计算公式表示,这里以 sigmod
为例:
整体的流转过程就如下图所示:
反向传播
反向传播过程相对复杂,是训练神经网络的核心过程,通过计算损失函数关于网络中各个参数的梯度来更新网络的权重和偏置。更新的主要依据便是梯度下降法,如图所示:
梯度下降法是一种用于寻找函数最小值的优化算法,主要用于衡量目标函数,在我们这里就是损失函数,衡量预测值与真实值之间的差异,然后根据目标函数计算出关于其不同参数的偏导数,也就是损失关于每个 w
和 b
的偏导数。
在高中数学中我们就知道导数表示着斜率,或者叫梯度,通过导数能知道往哪个方向移动可以最小化目标函数,梯度下降也是基于这一理念。但是神经网络的组成必然是一个复杂的函数,针对这一复杂函数的求导就要用到我们大学数学微积分中的偏导数和链式法则了,如果忘记了,没有关系因为我也忘记了,但是一看概念就记起来了。
其中导数一般是针对单变量函数的。如果一个函数依赖一个变量,比如 y = f(x)
,那么这个函数相对于变量 x
的导数,描述的就是当 x
发生变化时,函数 y
变化的速率,可以理解为函数图形在某一点的切线斜率。
而偏导数一般是针对多变量函数的。如果一个函数依赖多个变量,比如 z = f(x, y)
,那么这个函数相对于其中一个变量的偏导数描述的是当这个变量发生变化,而其他变量保持不变时,函数变化的速率。
例如:z = x**2 + y**2
的关于 x
和 y
时的偏导数:
关于 x
的偏导数:
关于 y
的偏导数:
但如果一个函数包含多个函数的输出,则计算其导数时,还要使用链式法则,例如有一个复合函数 f(g(x))
,其中 f
是外层函数,g
是内层函数,那么其导数使用链式法则可以表述为:
通过偏导数+链式法则就可以计算出 loss
关于每个权重 w
和 b
的梯度,有了梯度那怎么优化参数呢,就是向梯度的反方向更新参数,具体更新多少呢,就要看我们指定的学习率是多少了,计算公式如下:
其中 α
表示学习率,L
表示损失函数。
损失函数
上面反向传播过程中多次用到损失函数,关于损失函数例如有经常见的MSE
均方差损失函数,以及经常使用的Cross Entropy
交叉熵损失函数。MSE
比较简单,但 MSE
对异常值比较敏感,后续我们使用交叉熵损失作为演示:
其中 交叉熵损失函数的公式为:
对于二分类问题:
多分类问题时:
有了这些前置知识,下面就容易实现神经网络了,关于多维向量的计算这里直接采用 Numpy
作为基础计算框架。
二、实现前向传播过程
前向传播比较容易理解,这里我们以两层神经网络为例,其中激活函数使用 sigmod
:
实现过程如下:
class NeuralNetwork:
def __init__(self, input_size, hidden_size, output_size):
# 输入大小
self.input_size = input_size
# 隐藏层的大小
self.hidden_size = hidden_size
# 输出层的大小
self.output_size = output_size
# 初始化第一层的权重参数
self.w1 = np.random.randn(self.input_size, self.hidden_size)
# 初始化第一层的偏置参数
self.b1 = np.zeros((1, self.hidden_size))
# 初始化第二层的权重参数
self.w2 = np.random.randn(self.hidden_size, self.output_size)
# 初始化第二层的偏置参数
self.b2 = np.zeros((1, self.output_size))
# 激活函数
def sigmoid(self, x, differentiate=False):
if differentiate: # 计算sigmoid激活函数的导数
return x * (1 - x)
else: # 计算sigmoid激活函数
return 1 / (1 + np.exp(-x))
# 前向传播
def forward(self, x):
# 第一层隐藏层的计算
self.hidden_output = self.sigmoid(np.dot(x, self.w1) + self.b1)
# 第二层输出层的计算
self.output = self.sigmoid(np.dot(self.hidden_output, self.w2) + self.b2)
return self.output
下面构建输入数据,执行一遍前向传播的过程:
# 构造输入数据
x = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
# 初始化网络
net = NeuralNetwork(input_size=2, hidden_size=4, output_size=1)
# 前向传播
print(net.forward(x))
运行后可以看到前向输出结果,由于现在没有目标值以及反向传播更新参数、这里的输出还是比较随机化的:
三、实现反向传播过程
反向传播我们首先的目的就是先求出损失函数关于每个 w
和 b
的梯度。
上面前向传播有了输入数据,这里我们再设定目标值:
# 构造输入数据
x = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
# 目标标签
y = np.array([[0], [0.85], [1], [0]])
这里的目标值不是固定的,你可以随意修改,但由于使用的是 sigmod
激活函数,所以目标值只能设置 0-1
之间的随意数字。
有了目标就可以通过损失函数计算损失,这里以交叉商损失为例,实现过程如下:
def cross_entropy_loss(output, y, differentiate=False):
# 为了防止计算log(0),对 output 做一个很小的正则化
epsilon = 1e-15
output = np.clip(output, epsilon, 1 - epsilon)
if differentiate: # 计算交叉熵的导数
return -(y / output - (1 - y) / (1 - output))
else: # 计算交叉熵损失
return -np.mean(y * np.log(output) + (1 - y) * np.log(1 - output))
计算损失,其中 output
为前向传播的结果:
loss = cross_entropy_loss(output, y)
有了损失就可以一步一步的根据链式法则求取 loss
关于每个 w
和 b
的梯度,这里演示下 w2
和 b2
的梯度如何计算的:
首先计算loss
的梯度:
d_loss = self.cross_entropy_loss(output, y, True)
依据链式法则可以计算出loss
关于输出output
的梯度:
d_output = d_loss * sigmoid(output, True)
再依据链式法则可以计算出loss
关于w2
的梯度:
d_w2 = np.dot(hidden_output.T, d_output)
再依据链式法则可以计算出loss
关于b2
的梯度:
d_b2 = d_output * 1
同理方式计算 loss
关于 w1
和 b1
的梯度,整体实现过程如下:
class NeuralNetwork:
... 衔接上面前向传播过程
# 交叉熵损失函数
def cross_entropy_loss(self, output, y, differentiate=False):
# 为了防止计算log(0),对 output 做一个很小的正则化
epsilon = 1e-15
output = np.clip(output, epsilon, 1 - epsilon)
if differentiate: # 计算交叉熵的导数
return -(y / output - (1 - y) / (1 - output))
else: # 计算交叉熵损失
return -np.mean(y * np.log(output) + (1 - y) * np.log(1 - output))
# 反向传播
def backward(self, x, y, output):
# 计算损失的梯度
d_loss = self.cross_entropy_loss(output, y, True)
# 依据链式法则,计算损失关于输出的梯度
d_output = d_loss * self.sigmoid(output, True)
# 依据链式法则,计算损失关于w2的梯度
d_w2 = np.dot(self.hidden_output.T, d_output)
# 依据链式法则,计算损失关于b2的梯度
d_b2 = d_output * 1
# 同理依据链式法则,计算隐藏层输出的梯度
d_hidden = np.dot(d_output, self.w2.T) * self.sigmoid(self.hidden_output, True)
# 同理,w1 的梯度
d_w1 = np.dot(x.T, d_hidden)
# 同理,b1 的梯度
d_b1 = d_hidden * 1
有了 loss
关于每个 w
和 b
的梯度,就可以根据梯度下降公式更新每个 w
和 b
的值了,这里需要有一个学习率的参数,计算公式依据上面前置知识中的公式,实现过程如下:
self.w2 -= learning_rate * d_w2
self.b2 -= learning_rate * np.sum(d_b2, axis=0, keepdims=True)
self.w1 -= learning_rate * d_w1
self.b1 -= learning_rate * np.sum(d_b1, axis=0, keepdims=True)
最后完整的过程为:
class NeuralNetwork:
def __init__(self, input_size, hidden_size, output_size):
# 输入大小
self.input_size = input_size
# 隐藏层的大小
self.hidden_size = hidden_size
# 输出层的大小
self.output_size = output_size
# 初始化第一层的权重参数
self.w1 = np.random.randn(self.input_size, self.hidden_size)
# 初始化第一层的偏置参数
self.b1 = np.zeros((1, self.hidden_size))
# 初始化第二层的权重参数
self.w2 = np.random.randn(self.hidden_size, self.output_size)
# 初始化第二层的偏执参数
self.b2 = np.zeros((1, self.output_size))
# 激活函数
def sigmoid(self, x, differentiate=False):
if differentiate: # 计算sigmoid激活函数的导数
return x * (1 - x)
else: # 计算sigmoid激活函数
return 1 / (1 + np.exp(-x))
# 前向传播
def forward(self, x):
# 第一层隐藏层的计算
self.hidden_output = self.sigmoid(np.dot(x, self.w1) + self.b1)
# 第二层输出层的计算
self.output = self.sigmoid(np.dot(self.hidden_output, self.w2) + self.b2)
return self.output
# 交叉熵损失函数
def cross_entropy_loss(self, output, y, differentiate=False):
# 为了防止计算log(0),对 output 做一个很小的正则化
epsilon = 1e-15
output = np.clip(output, epsilon, 1 - epsilon)
if differentiate: # 计算交叉熵的导数
return -(y / output - (1 - y) / (1 - output))
else: # 计算交叉熵损失
return -np.mean(y * np.log(output) + (1 - y) * np.log(1 - output))
# 反向传播
def backward(self, x, y, output, learning_rate):
# 计算损失的梯度
d_loss = self.cross_entropy_loss(output, y, True)
# 依据链式法则,计算损失关于输出的梯度
d_output = d_loss * self.sigmoid(output, True)
# 依据链式法则,计算损失关于w2的梯度
d_w2 = np.dot(self.hidden_output.T, d_output)
# 依据链式法则,计算损失关于b2的梯度
d_b2 = d_output * 1
# 同理依据链式法则,计算隐藏层输出的梯度
d_hidden = np.dot(d_output, self.w2.T) * self.sigmoid(self.hidden_output, True)
# 同理,w1 的梯度
d_w1 = np.dot(x.T, d_hidden)
# 同理,b1 的梯度
d_b1 = d_hidden * 1
# 梯度下降更新参数
self.w2 -= learning_rate * d_w2
self.b2 -= learning_rate * np.sum(d_b2, axis=0, keepdims=True)
self.w1 -= learning_rate * d_w1
self.b1 -= learning_rate * np.sum(d_b1, axis=0, keepdims=True)
四、迭代训练
上面前向传播、反向传播实现后,核心的内容就已经基本结束了,训练过程就比较简单了,通过循环往复的计算梯度更新参数,使 loss
值最小化,达到预测值逐渐逼近真实值的过程。
实现过程:
import matplotlib.pyplot as plt
# 构造输入数据
x = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
# 标签
y = np.array([[0], [0.85], [1], [0]])
# 初始化网络
net = NeuralNetwork(input_size=2, hidden_size=4, output_size=1)
# 训练
epochs = 5000 ## 迭代周期
learning_rate = 0.1 ## 学习率
# 绘图数据
plot_x, plot_y = [], []
for epoch in range(epochs):
# 前向传播
output = net.forward(x)
# 反向传播更新参数
net.backward(x, y, output, learning_rate)
# 计算损失
loss = net.cross_entropy_loss(output, y)
plot_x.append(epoch)
plot_y.append(loss)
# 每1000轮打印一次
if epoch % 1000 == 0 or epoch == epochs-1::
print(f"epoch: {epoch}, loss: {loss}")
# 测试模型的输出
print("=====最终输出======")
np.set_printoptions(suppress=True)
output = net.forward(x)
print(output)
# 绘制loss曲线图
plt.plot(plot_x, plot_y)
# 添加标题和标签
plt.xlabel('epoch')
plt.ylabel('loss')
# 显示图表
plt.show()
运行后可以看到 loss
曲线图:
以及最后测试的结果:
从日志中可以看出,在 5000
个 epoch
下损失最终降到 0.108
左右,并且最后的预测结果已经非常趋于真实值了。
如果我们将真实值随意修改为,但注意要在0-1
范围内:
y = np.array([[0.96], [0.63], [0.75], [0.45]])
这里你可以任意修改,然后再次训练,查看日志结果:
仍然非常趋于真实值,因为整个过程是以结果为导向的。
五、总结
到此整个从零实现神经网络前向传播、反向传播、迭代训练过程就结束了。通过上述内容你应该了解了神经网络的基本训练过程,包括前向传播、反向传播以及梯度下降。虽然梯度下降是一种有效的优化算法,但它也存在一些局限性。其中一个主要问题是局部最优解。由于梯度下降算法是沿着损失函数的梯度下降,如果损失函数是非凸的,存在多个 山峰 和 山谷 ,算法可能会陷入局部最优解,而不是全局最优解。不过不用过于担心,实际我们训练网络模型时使用的 Adam
优化器,已经结合了动量和自适应学习率,或者 RMSprop
优化器为每个参数调整学习率,这些优化方式都有助于优化局部最优。