单入单出的两层神经网络能做什么

定义神经网络结构

我们定义一个两层的神经网络,输入层不算,一个隐藏层,含128个神经元,一个输出层。

数学理论证明:具有足够数量神经元的两层神经网络能够拟合任意精度的连续函数。所以,今天咱们就用实际数据来验证一下这个理论。我们假设一个连续函数的形式为:

\[y=0.4x^2 + 0.3xsin(15x) + 0.01cos(50x)-0.3\]

输入层

输入层就是一个标量X值。

权重矩阵W1/B1

它是连接两层之间的纽带,有的人理解它应该属于输入层,有的人理解应该属于隐藏层,各有各的道理,我个人倾向于把它归到隐藏层,理由是\(Z1=W1*X+B1\),在X固定的前提下,W1决定了Z1的值。另外一个理由是B1的存在位置,在本例中B1是一个128×1的矩阵,它是隐藏层128个神经元的偏移,所以它应该属于隐藏层。

其实这里的B1所在的圆圈里应该是个常数1,而B1连接到Z1-1…Z1-128的权重线B1-1…B1-128应该是个浮点数。我们为了说明问题方便,就写了个B1,而实际的B1是指B1-1…B1-128的矩阵/向量。

W1的尺寸是128×1,B1的尺寸是128×1。

隐藏层

我们用一个128个神经元的网络来模拟函数,这个大家可以自己试验一下,把代码中的神经元数量修改一下,然后在保持迭代次数和其它(超)参数不变的情况,看看最终的精确度有何区别,训练时间的差异,以及内存占用有何差异。

每个神经元的输入\(Z1 = W1 * X + B1\),我们在这里使用双曲sigmoid正切函数,所以输出是\(A1 = sigmoid(Z1)\)。当然也可以使用其它激活函数如果tanh, Relu等等。

权重矩阵W2/B2

与W1/B1类似,我个人认为它属于输出层。W2的尺寸是1×128,B2的尺寸是1×1。

输出层

由于我们只想完成一个拟合任务,所以输出层只有一个神经元。它们的左侧是\(Z2=W2*A1+B2\),右侧是\(A2=Z2\)

为什么在最后一步没有用激活函数,而是直接令A2=Z2呢?我们后面再说。

创造训练数据

让我们先自力更生创造一些模拟数据:

import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path

def TargetFunction(x):
    p1 = 0.4 * (x**2)
    p2 = 0.3 * x * np.sin(15 * x)
    p3 = 0.01 * np.cos(50 * x)
    y = p1 + p2 + p3 - 0.3
    return y

def CreateSampleDataXY(m):
    S = np.random.random((m,2))
    S[:,1] = TargetFunction(S[:,0])
    return S

def CreateTestData(n):
    TX = np.linspace(0,1,100)
    TY = TargetFunction(TX)
    TZ = np.zeros(n)
    return TX, TY, TZ

其函数图像在[0,1]之间的样子是:

生成的数据格式如下:

\[ \begin{pmatrix} x_1, y_1\\ x_2, y_2\\ \dots\\ x_m, y_m\\ \end{pmatrix} \]

其中,x就是上图中蓝色点的横坐标值,y是纵坐标值。在[0,1]之外的函数曲线没这么复杂,似乎拟合起来没什么难度,所以我们特点选择了[0,1]之间这一段来做试验。

定义前向计算过程

至此,我们得到了以下一串公式:

\[Z1=W1*X+B1\]
\[A1=sigmoid(Z1)\]
\[Z2=W2*A1+B2\]
\[A2=Z2 \tag{这一步可以省略}\]

def ForwardCalculation(x, dictWeights):
    W1 = dictWeights["W1"]
    B1 = dictWeights["B1"]
    W2 = dictWeights["W2"]
    B2 = dictWeights["B2"]

    Z1 = np.dot(W1,x) + B1
    A1 = sigmoid(Z1)
    Z2 = np.dot(W2,A1) + B2
    A2 = Z2  # 这一步可以省略

    dictCache ={"A1": A1, "A2": A2}
    return A2, dictCache

由于参数较多,所以我们用一个dictionary(dictWeights)来保存W,B这些参数,如果是更多层的神经网络,就会有更多的参数,我们这里使用的还是一些最基本的参数。

定义代价函数

我们用传统的均方差函数: \(loss = \frac{1}{2}(Z-Y)^2\),其中,Z是每一次迭代的预测输出,Y是样本标签数据。我们使用所有样本参与训练,因此损失函数实际为:

\[Loss = \frac{1}{2}(Z – Y) ^ 2\]

其中的分母中有个2,实际上是想在求导数时把这个2约掉,没有什么原则上的区别。

定义针对w和b的梯度函数

看一下计算图,然后用链式求导法则反推:

求W1的梯度

因为:

\[Z2 = W2*A1+B2\]

\[Loss = \frac{1}{2}(Z2-Y2)^2\]

所以我们用Loss的值作为基准,去求w对它的影响,也就是loss对w的偏导数:

\[ \frac{\partial{Loss}}{\partial{W2}} = \frac{\partial{Loss}}{\partial{Z2}}*\frac{\partial{Z2}}{\partial{W2}} \]

其中:

\[ \frac{\partial{Loss}}{\partial{Z2}} = \frac{\partial{}}{\partial{Z2}}[\frac{(Z2-Y)^2}{2}] = Z2-Y \]

而:

\[ \frac{\partial{Z2}}{\partial{W2}} = \frac{\partial{}}{\partial{W2}}(W2*A1+B2) = A1^T \]

所以:

\[ \frac{\partial{Loss}}{\partial{W2}} = \frac{\partial{Loss}}{\partial{Z2}}*\frac{\partial{Z}}{\partial{W2}} = (Z2-Y)*A1^T \]

矩阵求导的理论部分较为复杂,请大家参考我们的《基本数学导数公式》章节。

求B2的梯度

\[ \frac{\partial{Loss}}{\partial{B2}} = \frac{\partial{Loss}}{\partial{Z2}}*\frac{\partial{Z2}}{\partial{B2}} \]

其中第一项前面算w的时候已经有了,而:

\[ \frac{\partial{Z2}}{\partial{B2}} = \frac{\partial{(W2*A1+B2)}}{\partial{B2}} = 1 \]

所以:

\[ \frac{\partial{Loss}}{\partial{B2}} = \frac{\partial{Loss}}{\partial{Z2}}*\frac{\partial{Z2}}{\partial{B2}} = Z2-Y \]

求W1的梯度

因为:

\[A1 = sigmoid(Z1)\]

\[Z1 = W1*X+B1\]

对Z1求导:

\[ \frac{\partial{Loss}}{\partial{Z1}} = \frac{\partial{Loss}}{\partial{Z2}}*\frac{\partial{Z2}}{\partial{A1}}*\frac{\partial{A1}}{\partial{Z1}} \]

其中前面推导过:

\[ \frac{\partial{Loss}}{\partial{Z2}} = Z2-Y = dZ2 \]

而:

\[ \frac{\partial{Z2}}{\partial{A1}} = \frac{\partial{}}{\partial{A1}}(W2*A1+B2) = W2^T \]

\[ \frac{\partial{A1}}{\partial{Z1}} = \frac{\partial{}}{\partial{Z1}}(sigmoid(Z1)) = A1*(1-A1) \]

所以:

\[ \frac{\partial{Loss}}{\partial{Z1}} = W2^T * dZ2 * A1 * (1-A1) = dZ1 \]
而W1,B1的求导结果和W2,B2类似:

\[ \frac{\partial{Loss}}{\partial{W1}} = \frac{\partial{Loss}}{\partial{Z1}}*\frac{\partial{Z1}}{\partial{W1}}=dZ1*\frac{\partial{(W1*X+B1)}}{\partial{W1}}=dZ1*X^T \]

\[ \frac{\partial{Loss}}{\partial{B1}} = \frac{\partial{Loss}}{\partial{Z1}}*\frac{\partial{Z1}}{\partial{B1}}=dZ1*\frac{\partial{(W1*X+B1)}}{\partial{B1}}=dZ1 \]

变成代码:

def BackPropagation(x, y, dictCache, dictWeights):
    A1 = dictCache["A1"]
    A2 = dictCache["A2"]
    W2 = dictWeights["W2"]

    dLoss_Z2 = A2 - y
    dZ2 = dLoss_Z2
    dW2 = dZ2 * A1.T
    dB2 = dZ2

    dZ2_A1 = W2.T * dZ2
    dA1_Z1 = A1 * (1 - A1)
    # dZ1 is dLoss_Z1
    dZ1 = dZ2_A1 * dA1_Z1
    dW1 = dZ1 * x
    dB1 = dZ1

    dictGrads = {"dW1":dW1, "dB1":dB1, "dW2":dW2, "dB2":dB2}
    return dictGrads

每次迭代后更新w,b的值

def UpdateWeights(dictWeights, dictGrads, learningRate):
    W1 = dictWeights["W1"]
    B1 = dictWeights["B1"]
    W2 = dictWeights["W2"]
    B2 = dictWeights["B2"]

    dW1 = dictGrads["dW1"]
    dB1 = dictGrads["dB1"]
    dW2 = dictGrads["dW2"]
    dB2 = dictGrads["dB2"]

    W1 = W1 - learningRate * dW1
    W2 = W2 - learningRate * dW2
    B1 = B1 - learningRate * dB1
    B2 = B2 - learningRate * dB2

    dictWeights = {"W1": W1,"B1": B1,"W2": W2,"B2": B2}

    return dictWeights

帮助函数

第一个show_result函数用于最后输出结果。第二个print_progress函数用于训练过程中的输出。

def sigmoid(x):
    s=1/(1+np.exp(-x))
    return s

def initialize_with_zeros(n_x,n_h,n_y):
    np.random.seed(2)
    # W1=np.random.randn(n_h,n_x)*0.00000001    # W1=np.random.randn(n_h,n_x)
    W1=np.random.uniform(-np.sqrt(6)/np.sqrt(n_x+n_h),np.sqrt(6)/np.sqrt(n_h+n_x),size=(n_h,n_x))
    # W1=np.reshape(32,784)
    B1=np.zeros((n_h,1))
    # W2=np.random.randn(n_y,n_h)*0.00000001  # W2=np.random.randn(n_y,n_h)
    W2=np.random.uniform(-np.sqrt(6)/np.sqrt(n_y+n_h),np.sqrt(6)/np.sqrt(n_y+n_h),size=(n_y,n_h))
    B2=np.zeros((n_y,1))

    assert (W1.shape == (n_h, n_x))
    assert (B1.shape == (n_h, 1))
    assert (W2.shape == (n_y, n_h))
    assert (B2.shape == (n_y, 1))

    dictWeights = {"W1": W1,"B1": B1,"W2": W2,"B2": B2}

    return dictWeights

主程序初始化

m = 1000
S = CreateSampleDataXY(m)
#plt.scatter(S[:,0], S[:,1], 1)
#plt.show()
n_input, n_hidden, n_output = 1, 128, 1
learning_rate = 0.1
eps = 1e-10
dictWeights = initialize_with_zeros(n_input, n_hidden, n_output)
max_iteration = 1000
loss, prev_loss, diff_loss = 0, 0, 0

程序主循环

for iteration in range(max_iteration):
    for i in range(m):
        x = S[i,0]
        y = S[i,1]
        A2, dictCache = ForwardCalculation(x, dictWeights)
        dictGrads = BackPropagation(x,y,dictCache,dictWeights)
        dictWeights = UpdateWeights(dictWeights, dictGrads, learning_rate)
    print("iteration", iteration)

测试并输出拟合结果

tm = 100
TX, TY, TZ = CreateTestData(tm)
correctCount = 0
for i in range(tm):
    x = TX[i]
    y = TY[i]
    a2, dict = ForwardCalculation(x, dictWeights)
    TZ[i] = a2

plt.scatter(TX, TY)
plt.plot(TX, TZ, 'r')
str = str.format("cell:{0} sample:{1} iteration:{2} rate:{3}", n_hidden, m, max_iteration, learning_rate)
plt.title(str)
plt.show()

上面的TX是[0,1]之间的连续数,共100个,间隔相同。TY是更加被模拟的函数计算出来的精确值。TZ是我们训练的模型的预测值。我们的目的就是要比较TY和TZ之间的差距。
下图就是拟合结果,还比较令人满意。

参数调整

经常听人说起“调参”,这次咱们亲身经历一下调参的痛(快)苦(乐)!我们下面一切的比较都是以下面这组参数为基准:

  1. 神经元数=128
  2. 输入训练数据量=1000
  3. 迭代次数=1000
  4. 权重调整步进值=0.1

以上这些标准值如何得到呢?试了很多组合后得到的,这就是所谓“试错”的过程了。

神经元数量的变化(标准值128)

神经元数量=64

神经元数量=96

神经元数量=256,迭代次数=500

基准神经元数为128,在96时,拟合效果很差,在64时,尽管我们增加了迭代次数为2000,仍然很差。
第三张图,尽管神经元数量翻了一倍,成为256个,但是迭代次数为500,少了一倍,也会造成奇怪的结果。

样本量的变化(标准值1000)

输入数据量=500

输入数据量=1500

样本数据量不够时,拟合效果不好。但是当样本数据量超过一定值后,就没多大作用了。

迭代次数的变化(标准值1000)

迭代次数=500

输入数据量=1500

迭代次数少,拟合效果不好。迭代次数超过一定值后,容易造成过拟合,效果不大。

步长值的变化(标准值0.1)

步长=0.5

步长=0.01

步长值太大或者太小,都会造成不好的效果。

总结如下(效果5分为最好):

神经元数量 样本量 迭代次数 步长值 效果
0 128 1000 1000 0.1 5
1 64 1000 2000 0.1 3
2 96 1000 1000 0.1 2.5
3 256 1000 500 0.1 0
4 128 500 1000 0.1 2
5 128 1500 1000 0.1 5
6 128 1000 500 0.1 2.5
7 128 1000 1500 0.1 5
8 128 1000 1000 0.5 1
9 128 1000 1000 0.05 2