面向开发工程师的 0 基础机器学习教程 - 神经网络(三)

接上次最简单的「与门」三维空间机器学习算法后,可以分自然地联想到「或门」、「非门」、「异或门」,随着实践的进行,你会发现在「或门」、「非门」还是非常顺畅的,但到了「异或门」,还是遇到了模型预测定义不够好的问题,即损失函数无法再下降的前提下,拟合还是太差。这个时候,机器学习下的一个分支,「深度学习」就要开始发挥他的作用了[1]。

异或门的特别之处

直观地理解其中差距,可以看「异或门」训练之后的平面,始终无法正确拟合所有的数据点,如下:

与门 异或门

参考上述的平台特征,原来的方案:“貌似只能下面这么折叠(有个平面必须平行 x 轴),无法折叠出 V 字型的平面”

看上去很简单,把激活函数改成 $x^2$ 看似能很完美地解决这个问题,不过,激活函数需要具备下述特性(以下原因来自 AI 回复):

  • 函数需单调,方向不一致,导致训练不稳定
  • 函数为偶函数,是对称输出,无法区分正负
  • 增长过快,容易梯度爆炸(直观理解,就是不好训练,权重、偏置可能训练了好几亿次,都没收敛)

那从输入着手呢,比如 ` y = sigmoid(w1 * x1 * x1 + w2 * x2 * x2 + b)` ,取得 x 的平方或 x 的三次方,会不会有更好的效果呢,答案是不会。

所以,需要另外的一种方式,让平台能够多折叠几次,最好是那种通用的,不管是什么函数,都有一个万能的模型来做相关预测,因为一开始模型具备是什么样子是不知道的,所以要有一个能模拟任何模型的函数。

好在这块领域,也有相关的理论支持,就是【通用近似定理/万能近似定理】[2],具体的表现就是多加一隐藏层。

神经网络

原有的与门模型定义:

\[\begin{aligned} h &= w_1 * x_1 + w_2 * x_2 + b \\ y &= ReLU(h) \end{aligned}\]

按神经网络的思路,其定义可以可视化为如下表达方式,即:

要解决原有与门模型「折叠次数」太少的问题,可以加一层 2 个神经元隐藏层的神经网络来解决,其表达方式如下:

中间层只加一个神经元行不行?答案是不行,这样本质上没有隐藏层的效果是一样的,只是单纯地在原有的 y 的基础上,再做了一次计算而已,所以,此处至少 2 个神经元(即 h1 和 h2)。

用数学表达方式就是下面这个样子(为方便起见,不涉及复杂导数计算,激活函数选用 ReLU )

\[\begin{aligned} h_1 &= w_1 * x_1 + w_3 * x_2 + b_1 \\ h_2 &= w_2 * x_1 + w_4 * x_2 + b_2 \\ y_1 &= ReLU(h_1) \\ y_2 &= ReLU(h_2) \\ h_3 &= w_5 * y_1 + w_6 * y_2 + b_3 \\ y &= ReLU(h_3) \end{aligned}\]

为了先不引入「矩阵」的概念及其计算方法,此处特意用 $w_1 - w_6$ 及 $b_1 - b_3$ 来表示,并用纯算术计算来实现上述机器学习。因为笔者本人一开始学的时候,矩阵下标和各种矩阵计算,就能把人搞得晕头转向的。

所以,再次说明一下,矩阵只是为了更好地做表达、并行计算,可以先放一边,按最简单的、概念最少的思路,把整体流程捋一遍。

反向传播计算

这里最关键的是 w5 和 w6 怎么更新,所以,先对其求偏导,先提取跟 L 和 w5 有关的函数

\[\begin{aligned} L &= (y - \hat{y}) ^2 \\ y &= ReLU(h_3) \\ h_3 &= w_5 * y_1 + w_6 * y_2 + b_3 \\ \end{aligned}\]

所以,w5 的偏导数有如下:

\[\begin{aligned} \frac{\partial L}{\partial w_5} &= \frac{\partial L}{\partial y} · \frac{\partial y}{\partial h_3} · \frac{\partial h_3}{\partial w_5}\\ &= \begin{cases} 2 * (y - \hat{y}) * 1 * y_1, & \text{if } h_3 > 0 \\ 2 * (y - \hat{y}) * 0 * y_1, & \text{otherwise} \end{cases} \\ &= \begin{cases} 2 * (y - \hat{y}) * y_1, & \text{if } h_3 > 0 \\ 0, & \text{otherwise} \end{cases} \end{aligned}\]

对 w1 的偏导如下

\[\begin{aligned} L &= (y - \hat{y}) ^2 \\ y &= ReLU(h_3) \\ h_3 &= w_5 * y_1 + w_6 * y_2 + b_3 \\ y_1 &= ReLU(h1) \\ h_1 &= w_1 * x_1 + w_3 * x_2 + b_1 \\ \frac{\partial L}{\partial w_1} &= \frac{\partial L}{\partial y} · \frac{\partial y}{\partial h_3} · \frac{\partial h_3}{\partial y_1}· \frac{\partial y_1}{\partial h_1}· \frac{\partial h_1}{\partial w_1}\\ &= \begin{cases} 2 * (y - \hat{y}) * 1 * w_5 * 1 * x_1, & \text{if } h_3 > 0, & h_1 > 0 \\ 0, & \text{otherwise} \end{cases} \\ \end{aligned}\]

Python 实现

由于随机初始化的问题,需要多运行几次(这可能就是算法为啥叫调参工程师的原因吧 ^-^ ),有时候会收敛到 y = 0.5 的平面

import random

# 学习率
rate = 0.01

# XOR 门测试数据
x = [[0, 0], [0, 1], [1, 0], [1, 1]]
y = [0, 1, 1, 0]

w1 = random.random()
w2 = random.random()
w3 = random.random()
w4 = random.random()
w5 = random.random()
w6 = random.random()
b1 = random.random()
b2 = random.random()
b3 = random.random()


# relu 激活函数
def relu(v):
    if v < 0:
        return 0
    else:
        return v

# 函数的定义
def h1(x1, x2):
    return w1 * x1 + w3 * x2 + b1

def h2(x1, x2):
    return w2 * x1 + w4 * x2 + b2

def h3(y1, y2):
    return w5 * y1 + w6 * y2 + b3


def y1(h1):
    return relu(h1)

def y2(h2):
    return relu(h2)

print("初始化的权重和偏置: ")
print(w1, w2, w3, w4, w5, w6, b1, b2, b3)

# 训练迭代
epoch = 10000
while epoch > 0:
    epoch = epoch - 1;
    
     # w 和 b 更新算法
    dw1 = dw2 = dw3 = dw4 = dw5 = dw6 = 0.0
    db1 = db2 = db3 = 0.0

    # 遍历测试数据集
    for xs, yhat in zip(x, y):
        [x1, x2] = xs

        # 预测值
        # 为区分函数和数值,所以新增 v 后缀
        h1_v = h1(x1, x2)
        h2_v = h2(x1, x2)
        y1_v = relu(h1_v)
        y2_v = relu(h2_v)
        h3_v = h3(y1_v, y2_v)
        y_v = relu(h3_v)

        # w5 和 w6 更新
        if h3_v > 0:
            dw5 += 2 * (y_v - yhat) * y1_v
            dw6 += 2 * (y_v - yhat) * y2_v
            db3 += 2 * (y_v - yhat) * 1

            if h1_v > 0:
                dw1 += 2 * (y_v - yhat) * w5 * x1
                dw3 += 2 * (y_v - yhat) * w5 * x2
                db1 += 2 * (y_v - yhat) * w5
            if h2_v > 0:
                dw2 += 2 * (y_v - yhat) * w6 * x1
                dw4 += 2 * (y_v - yhat) * w6 * x2
                db2 += 2 * (y_v - yhat) * w6


    dw1 = dw1 / len(x)
    dw2 = dw2 / len(x)
    dw3 = dw3 / len(x)
    dw4 = dw4 / len(x)
    dw5 = dw5 / len(x)
    dw6 = dw6 / len(x)
    db1 = db1 / len(x)
    db2 = db2 / len(x)
    db3 = db3 / len(x)

    # 更新
    w1 = w1 - rate * dw1
    w2 = w2 - rate * dw2
    w3 = w3 - rate * dw3
    w4 = w4 - rate * dw4
    w5 = w5 - rate * dw5
    w6 = w6 - rate * dw6
    b1 = b1 - rate * db1
    b2 = b2 - rate * db2
    b3 = b3 - rate * db3

print("训练后的权重和偏置: ")
print(w1, w2, w3, w4, w5, w6, b1, b2, b3)


def forword(x1, x2):
    h1_v = h1(x1, x2)
    h2_v = h2(x1, x2)
    y1_v = relu(h1_v)
    y2_v = relu(h2_v)
    h3_v = h3(y1_v, y2_v)
    y_v = relu(h3_v)
    print(f"x1 {x1}, x2 {x2} => y1 {y1_v}, y2 {y2_v} => y {y_v}")
    return y_v

print("预测结果:")
forword(0, 0)
forword(0, 1)
forword(1, 0)
forword(1, 1)

输出结果:

初始化的权重和偏置: 
0.9550195641305104 0.14966699439564202 0.3724530186688523 0.2918470706407119 0.9307877788007731 0.15420500041785823 0.6885635070835702 0.007327282692478065 0.6720023468053917
训练后的权重和偏置: 
0.9411855394820262 1.081520036084144 0.9411855383955807 1.0815200346093992 1.0624827954556706 -1.8492392682881917 0.10083647508443963 -1.0815200439276933 -0.10713294170194569
预测结果:
x1 0, x2 0 => y1 0.10083647508443963, y2 0 => y 4.0782296658048445e-06
x1 0, x2 1 => y1 1.0420220134800204, y2 0 => y 0.999997520106653
x1 1, x2 0 => y1 1.0420220145664658, y2 0 => y 0.9999975212609825
x1 1, x2 1 => y1 1.9832075529620468, y2 1.0815200267658498 => y 1.6602024642403679e-06

成功完成模型的训练后,可能得到的一个曲面如下:

成功拟合1 成功拟合2

当然也有失败的,一开始折叠的方向错了,可能就落到局部最优,而不是全局最优,或者一开始就走了躺平路线的,所以,初始化很重要。

失败拟合1 失败拟合2

PyTorch 版本

import torch
import torch.nn as nn

# 测试数据
x = torch.tensor([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = torch.tensor([[0.0], [1.0], [1.0], [0.0]])

# 创建预测模型,包含一个输入层(2个参数)、一个隐藏层(2个神经元)、一个输出,并在每个输出层添加一个 ReLU 激活函数
model = nn.Sequential(nn.Linear(2, 2), nn.ReLU(), nn.Linear(2, 1), nn.ReLU())

# 定义均方误差损失函数
mseloss = torch.nn.MSELoss()
# 随机梯度下降优化器
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)  # 学习率为0.1, learning rate

# 执行梯度下降算法进行模型训练
for epoch in range(10000):
    y_pred = model(x)  # 计算预测值
    loss = mseloss(y_pred, y)  # 计算损失
    
    optimizer.zero_grad()  # 清零梯度
    loss.backward()  # 反向传播,计算梯度
    optimizer.step()  # 更新模型参数

# 预测
print(model.forward(torch.tensor([0.0, 0.0])))
print(model.forward(torch.tensor([0.0, 1.0])))
print(model.forward(torch.tensor([1.0, 0.0])))
print(model.forward(torch.tensor([1.0, 1.0])))

输出(同样要执行多次,有概率失败):

tensor([1.0848e-05], grad_fn=<ReluBackward0>)
tensor([1.0000], grad_fn=<ReluBackward0>)
tensor([1.0000], grad_fn=<ReluBackward0>)
tensor([1.0252e-05], grad_fn=<ReluBackward0>)

But, Why?

为什么加了中间层,有如此神奇的效果?

其实上述的权重,用矩阵的方式来描述,大致如下:

\[ReLU(\begin{bmatrix} w_1 & w_3 \\ w_2 & w_4 \end{bmatrix} \cdot \begin{matrix} x_1 \\ x_2 \end{matrix} + \begin{bmatrix} b_1 \\ b_2 \end{bmatrix} ) = \begin{bmatrix} ReLU(w_1*x_1 + w_3*x_2 + b_1)\\ ReLU(w_2*x_1 + w_4*x_2 + b_2) \end{bmatrix} = \begin{bmatrix} y_1 \\ y_2 \end{bmatrix}\]

这里使用一种通过上述模型预测出的权重和偏置,能满足 xor 预测模型(此处有模型有多个解,这里取一种最简单的解法),即当权重和偏置取如下值的时候,损失为 0,能正常预测 xor 模型。

输入层到隐藏层的计算结果:

w1 = 1, w2 = 1, w3 = 1, w4 = 1, b1 = 0, b2 = -1

\[ReLU(\begin{bmatrix} 1 & 1 \\ 1 & 1 \end{bmatrix} \cdot \begin{bmatrix} x_1 \\ x_2 \end{bmatrix} + \begin{bmatrix} 0 \\ -1 \end{bmatrix} ) = \begin{bmatrix} ReLU(w_1*x_1 + w_3*x_2 + b_1)\\ ReLU(w_2*x_1 + w_4*x_2 + b_2) \end{bmatrix} = \begin{bmatrix} y_1 \\ y_2 \end{bmatrix}\]

中间隐藏层到输出结果的计算如下:

w5 = 1, w6 = -2, b3 = 0 \(ReLU(\begin{bmatrix} 1 & -2 \\ \end{bmatrix} \cdot \begin{bmatrix} y_1 \\ y_2 \end{bmatrix} + \begin{bmatrix} 0 \end{bmatrix} ) = 1 * y_1 -2 * y_2 = y\)

线形变换

用矩阵写的 w1-w4,从线形代数的角度看,就是线形变换。

此处的线形变换,可以简单理解为,用一种技术,对现有的二维坐标系做缩放、旋转、裁剪、翻转等操作,使得相关坐标点更容易拟合。

根据上述的预测模型,相关的二维坐标系变化如下:

  • 横坐标为 x1,纵坐标为 x2
  • 蓝色点表示预期输出为 0,红色竖线表示预期输出为 1

此处最为关键的是两次线性变换,即第一次的 transform-1

\[\begin{bmatrix} 1 & 1 \\ 1 & 1 \end{bmatrix} \cdot \begin{bmatrix} x_1 \\ x_2 \end{bmatrix}\]

其变换之后,将 x1, x2 的原有 4 个点都压缩到一条 45 度的斜线上。然后通过 bias 和 relu,将其输出为 [0,0],[1,0],[2,1] 三个点。

类似如下这种,关于线性变换的更多内容,参考 3blue1brown 的线性代数的本质[3]:

第二次的 transform-2,改成为二维的线性变换,即为

\[\begin{bmatrix} 1 & -2 \\ 0 & 0 \end{bmatrix} \cdot \begin{bmatrix} y_1 \\ y_2 \end{bmatrix}\]

其变换之后,将 [2, 1] 线形变换到 [0, 0],使得最终的点为 [0, 0], [1, 0]

相关系列

限于本人学识与能力,文中难免存在疏漏与不足之处,恳请读者不吝指正。

参考

  1. 深度学习
  2. 通用近似定理/万能近似定理
  3. 3Blue1Brown_线代本质第三章:矩阵与线性变换