BP神经网络
隐藏层层数和神经元个数的确定
输入、输出的神经元个数很好确定,因为输入、输出对应着你的实际需求,但是隐藏层就比较难确定了。一般情况下,各个隐藏层的神经元个数相同即可。层数不宜过多,神经元添加过多的时候可以考虑增加层数。
有些可以参考的经验公式,例如有大神分享自己的经验公式
其中、、分别代表输入层、输出层的神经元个数以及训练样本数。
是可调值,一般在2~10。
还有可参考的一些依据,例如,等等
目前代码里实现的是一个隐藏层,神经元个数可调。
reference:https://zhuanlan.zhihu.com/p/47519999
reference:https://zhuanlan.zhihu.com/p/100419971
数据准备
数据的存储可以使用csv文件,然后使用pandas库,读取和写入都十分方便。
注意如果单纯使用到数据的话,要看情况使得header=None。
还有就是注意维度为1的,要squeeze一下。
因为维度为1是长度为1的列表,而不是单纯一个数据。
对于输出分类,可以转换成二进制,增加输出层神经元的个数。
转换也十分方便,可以用numpy的identity生成的方阵访问下标轻松获取。
代码如下:
# 数据准备 (x,y) -> (examples_num * input_num, examples_num * output_num)
train_data = pd.read_csv('./data/trainData.csv', header=None)
train_data = shuffle(train_data) # 打乱数据
x_train = train_data.iloc[:, 0:4].values
y_train = train_data.iloc[:, 4:5].values
y_train = np.squeeze(y_train)
y_train = np.identity(3)[y_train] # 将标签转变为二进制
激活函数
隐藏层和输出层之前都需要激活,激活函数起到非线性的作用。
常见的有sigmoid和tanh。
两者比较大的差别就是前者范围是(0,1),后者是(-1,1)。
sigmoid的表达式是,求导是。
tanh的表达式是。,求导是。
代码实现如下:
# Activation Function ("sigmoid", "tanh")
self._activationToHideName = "sigmoid"
if self._activationToHideName == "sigmoid":
self._activationToHide = lambda i: 1 / (1 + np.exp(-i))
self._pd_activationToHide = lambda i: i * (1 - i) # 偏导
elif self._activationToHideName == "tanh":
self._activationToHide = lambda i: (np.exp(i) - np.exp(-i)) / (np.exp(i) + np.exp(-i))
self._pd_activationToHide = lambda i: 1 - i * i # 偏导
损失函数
损失函数是来衡量网络输出与实际值的差距的。常见有均方误差(MSE)和交叉熵(Cross-entropy)。
假设输出是,实际值是。
则MSE的表达式是 。偏导数是。
交叉熵的表达式是 。偏导数是。
结合前面激活函数可以发现,如果使用交叉熵为损失函数和用sigmoid为激活函数,
在反向传播时偏导是要相乘的,所以可以消掉。这也是很多人把这两个结合起来一起用的原因吧。
一般选择sigmoid为输出层激活函数时,不会选择MSE,会导致权重更新过慢。
而用tanh为输出层激活函数时,不能用交叉熵,因为tanh输出包含负数。
代码实现如下:
# Loss Function ("MSE", "Cross-entropy")
self._loss_functionName = "Cross-entropy"
if self._loss_functionName == "MSE":
self._loss_function = lambda x, y: 1 / 2 * (y - x) * (y - x)
self._pd_loss_function = lambda x, y: -(y - x)
elif self._loss_functionName == "Cross-entropy":
self._loss_function = lambda x, y: -(np.log(x) * y + (1 - y) * np.log(1 - x))
self._pd_loss_function = lambda x, y: -(y - x) / (x * (1 - x))
另外代码中加入了L2正则化
所以损失函数还要加上。
def compute_cost(self, output):
return np.sum(self._loss_function(output, self._y_train)) / self._sample_num + \
self._reg / 2 * (np.sum(np.square(self._w1)) + np.sum(np.square(self._w2))) / self._sample_num # L2正则化
正向传播
正向传播分为输入层到隐藏层以及隐藏层到输出层。
层与层之间也是分为线性计算和激活函数两部分。
首先是输入层到隐藏层,
假设输入是,权值是,偏置是,激活函数是,隐藏层是。
则从输入层到隐藏层可以表示为 。
隐藏层到输出层同理
权值是,偏置是,激活函数是,输出层是。
则
这样就实现了整个正向传播的过程
上述的权值和输入是否需要转置是根据自己确定
值得一提的是,当输入是n个样本时,和是不同维度的。
前者是隐藏层神经元个数*样本数,后者是 隐藏层神经元个数*1。
虽然说在python代码里是可以直接相加的,但是还是要理解清楚各参数维度。
因为一旦样本数为n时,后面很多地方都需要理解分析是否求了所有样本和而需要除以n。
代码如下:
def forward(self, x):
"""前向传播"""
a1 = np.dot(x, self._w1) + self._b1 # 维度不同的矩阵相加
hide = self._activationToHide(a1)
a2 = np.dot(hide, self._w2) + self._b2
output = self._activationToOutput(a2)
return hide, output
反向传播
反向传播其实就是求总损失对各个参数的偏导,然后用于更新参数。
求偏导运用到链式求导法则,这个需要自己推过一遍才更好理解。
注意求输入层到隐藏层时的偏导来自于输出层的很多部分,要逐一求后叠加。
代码如下:
def backward(self, hide, output):
"""反向传播"""
# output -> hide
d_activate2out = self._pd_loss_function(output, self._y_train) * self._pd_activationToOutput(output)
dw2 = np.dot(hide.T, d_activate2out) / self._sample_num
db2 = np.sum(d_activate2out, axis=0, keepdims=True) / self._sample_num
# hide -> input
d_activate2hide = np.dot(d_activate2out, self._w2.T) * self._pd_activationToHide(hide)
dw1 = np.dot(self._x_train.T, d_activate2hide) / self._sample_num
db1 = np.sum(d_activate2hide, axis=0, keepdims=True) / self._sample_num
reference:https://www.cnblogs.com/charlotte77/p/5629865.html
更新参数
更新参数用到的就是反向传播求出来的偏导
假设更新的是,则就是求。
然后更新参数。其中是学习率。
学习率这里值得一提一下,在训练初期需要较高的学习率,而在训练后期则需要较低的学习率。
所以学习率应该是一个和迭代次数有关的单调递减函数。
代码如下:
self._base_lr = 0.01
self._gamma = 0.001
self._lr = self._base_lr * pow(1 + self._gamma * i, 0.75) # 根据迭代次数更新学习率
另外代码中加入了L2正则化,所以有
dw2 += self._reg / self._sample_num * self._w2
dw1 += self._reg / self._sample_num * self._w1
训练
训练其实就是把上述的过程结合起来,
根据迭代次数不断正向传播、反向传播、更新参数、打印损失函数值。
代码如下:
def train(self):
"""训练"""
for i in range(self._iter_num):
hide, output = self.forward(self._x_train)
update_data = self.backward(hide, output)
self._lr = self._base_lr * pow(1 + self._gamma * i, 0.75) # 根据迭代次数更新学习率
self.update_parameter(*update_data)
cost = self.compute_cost(output)
if i % 1000 == 0:
print(f"\033[31m 迭代次数{i},损失函数值{cost}\033[0m")
测试
最后是通过训练出来的参数,对测试样本进行正向传播,看看效果。
这里采用取网络输出值和实际值最大值的下标,
通过比较就可以知道相似度了
代码如下:
def test(self):
_, test_out = self.forward(self._x_test)
# 对比网络输出和实际值 取最大值的下标进行对比
test_out = np.argmax(test_out, axis=1)
y_test = np.argmax(self._y_test, axis=1)
print("预测准确率:{:.2f}%".format(np.sum(test_out == y_test) / len(test_out) * 100))
图形界面显示
画图显示可以更直观看出数值变化,所以我就自己写了一个Display的类,输入数值即可看到随时间的变化。
有动态时间轴显示和静态时间轴显示两种方法。
前者是时间轴会不断向左移动,旧的数据会逐步往左消失掉,避免数据量太大,导致显示的比例尺不对。
后者是时间轴不会移动,旧的数据不会消失掉,用于最后观察整个过程数据的变化。
代码如下:
import matplotlib.pyplot as plt
from collections import deque
import time
class Display:
def __init__(self):
self._fig, self._ax = plt.subplots()
self._start_time = time.time()
# 随着时间轴的推移,负轴部分的数据应该舍弃 而负的数据在头部 为了提高效率选用单向队列FIFO
self._time = deque()
self._y = deque()
self._threshold = -1.0 # 较小数舍弃阈值 影响显示画面最左侧
self._timeline_left_parameter = 0.2 # 时间轴左移速度 影响画面往右更新
def dynamic_timeline_show(self, data):
# plt.ion()
# 要显示的数据
self._time.append(time.time() - self._start_time)
self._y.append(data)
# 显示前先清空上一帧
self._ax.cla()
# 动态设置x范围后画出
self._ax.set_xlim(0, self._time[-1] + 10)
self._ax.plot(self._time, self._y, c="b")
# 时间轴左移
for i in range(len(self._time)):
self._time[i] -= self._timeline_left_parameter
# 较小数舍弃
if self._time[0] < self._threshold:
self._time.popleft()
self._y.popleft()
# 延时
plt.pause(0.1)
# plt.ioff()
def stationary_timeline_show(self, data):
# plt.ion()
self._time.append(time.time() - self._start_time)
self._y.append(data)
self._ax.plot(self._time, self._y, c="b")
# 延时
plt.pause(0.1)
学习感想
神经网络的学习,公式的推导还是需要自己去手写去理解,自己推导过一遍才能理解更深。
刚开始学的时候可以把每一项都写出来,也便于矩阵相乘理解。
把正向传播和反向传播都推导过一遍,理解各个层的维度变化。
然后增加到n组样本,发现其实就是改个数字,有些地方要注意除以样本数而已。
开学前的最后一篇学习笔记也写完了√