通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

解剖麻雀,是分析了解复杂问题的好办法。本文通过搭建只有两个神经元的网络,从根本上剖析 Pytorch 工作原理。

先附上全部源代码,然后听我慢慢唠!

import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(1, 2)
        self.fc2 = nn.Linear(2, 1)
    def forward(self, x):
        x = torch.sigmoid(self.fc1(x))
        x = self.fc2(x)
        return x
net = Net()
x = torch.linspace(0, 1, 10).reshape(10, 1)
y = x*x - 0.5*x + 1.5625    

import torch.optim as optim
optimizer = optim.SGD(net.parameters(), lr = 0.001)
for n in range(0, 100000):
    optimizer.zero_grad()
    loss = sum(abs(net(x) - y))
    loss.backward()
    optimizer.step()
    if n % 1000 == 0:
        print(n, loss)
print('Finished Training!')

import matplotlib.pyplot as plt
plt.plot(x, y, "k*")

z=[]
x = torch.linspace(0,1,100).reshape(100,1)
for xx in x:
    zz = net(xx)
    z.append(zz)
plt.plot(x, z, "b-")

1. 两个神经元的神经网络

我计划搭建一个极简的神经网络模型。这个模型用来拟合 通过两个神经元的极简模型,清晰透视 Pytorch 工作原理 区间的一条抛物线,如果用 sigmoid 作为激活函数,两个神经元就够了。

.至于为什么只需要两个神经元,我会另写文章来加以说明。我遇到不少程序员,对于现有网络框架的调参、训练很熟练,但是一旦要他们自己创建新网络,往往不知道从何入手。原因在于对神经网络工作原理不理解。我建议,学习深度学习理论,应该自己经常搭建小型网络模型,逐步养成遇到问题能因地制宜有针对性地搭建网络模型。不要动不动就动用什么 yolo、ssd、deepsort 等现成的框架来应付差事。
.
.深度学习领域,程序员比拼的是建模能力,而不是调参能力。

网络模型用数学公式来表示如下:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

共计 7 个参数。模型示意图如下:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

2. Pytorch 模型

前面的神经网络,如何用 Pytorch 来表示呢?Pytorch 用 OOP 的编程模式来实现这个模型,具体地讲,就是以 torch.nn.Model 为基类,派生一个网络模型实例类。本文代码如下:

import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(1, 2)
        self.fc2 = nn.Linear(2, 1)
    def forward(self, x):
        x = torch.sigmoid(self.fc1(x))
        x = self.fc2(x)
        return x

这个模型实现了公式(1)中的函数,开始的时候,其参数 通过两个神经元的极简模型,清晰透视 Pytorch 工作原理 是随机的。

接下来我们看看这个模型如何工作。在 Python 环境下执行完上述命令后,继续输入下面的命令(我用的是 IPython 环境):

net = Net()
x = torch.tensor([0.5])
y = net(x)
print(y)

----------------------------------------------
tensor([-0.4313], grad_fn=<AddBackward0>)

2.1 Python 强大的 OOP 机制简化 Pytorch 应用接口

从大的方面看,我对下述代码的运行机制很感兴趣:

class Net ...
net = Net()
y = net(x)

Net 是一个类,net 是类 Net 的实例,但 net 在后面却被当成函数使用,貌似只有 C++ 才有的机制呀!这一番神操作是如何做到的呢?下面我写一段代码展示一下:

class Mul:
    rate = 100
    def __call__(self, x):
        return x * self.rate

mul = Mul()
mul(123)
----------------------------------------------
12300

net(x) 看上去像普通函数调用,实际上调用的是类 Net 的实例 net__call__ 方法。这种表达方式,说明 Python 语言表现能力非常强大,难怪 Python 被用来开发各种系统级的软件包。

为什么要费劲用一个 class 来伪装成 function 呢?原因有两点:

  • net 在执行 __call__ 的时候,需要利用内部属性变量记录计算过程中的某些状态。
  • net 在执行 __call__ 的时候,需要利用内部属性变量定义的某些计算规则。前面的例子中,net(x) 执行过程中要参考神经网络模型中的计算过程定义。

这个技巧掩盖了 net 函数计算过程背后的复杂性,使得 Pytorch 在应用过程中更加简练。

2.2 定义参数

我们知道在 (1) 式中,算法模型定义中用到了 7 个参数。这七个参数是在类 Net 的 init 方法中定义的,代码如下:

	def __init__(self):
      	super(Net, self).__init__()
        self.fc1 = nn.Linear(1, 2)
        self.fc2 = nn.Linear(2, 1)

这段代码在类 Net 构建的时候执行。它说明 Net 模型包含了两个线性变换计算,第一个 self.fc1 是 1 个输入和 2 个输出的线性变换, 第二个 self.fc2 是 2 个输入和 1 个输出的线性变换。具体一点,定义了如下变换:

self.fc1:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理
self.fc2
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

注意,这个定义中不包括 sigmoid 函数的运算,原因是该计算不包含任何参数,因此无需在 __init__ 方法中展现。实际上:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理
或简化为:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

总而言之,需要含待确定参数的运算过程,在 __init__ 中定义。那些没有参数,或者不需要通过训练参数的运算,无需在此定义。

2.3 定义计算过程

forward 方法中,要定义完整的计算过程,以便根据输入 x 计算模型的输出 y。代码如下:

    def forward(self, x):
        x = torch.sigmoid(self.fc1(x))
        x = self.fc2(x)
        return x

我们又看到神奇的一幕,self.fc1self.fc2__init__ 代码中明明是一个变量,怎么摇身一变成函数了?这个戏法和代码 net = Net(),然而 y = net(x) 是同一个原理。函数 nn.Linear(1, 2) 返回了一个类的实例,该实例重载了 __call__ 方法。

最重要的是,x = torch.sigmoid(self.fc1(x)) 这种代码,看上去和我们编写的普通函数无异,其实它并没有实际执行计算,只是把定义的计算公式以某种数据结构保存在 Net 内部了。真正的计算是在函数 __call__ 中进行的,也就是在执行 y = net(x) 的时候进行的。__call__ 函数依据定义的公式和输入数据计算输出结果。

2.4 Pytorch 中计算过程的定义

计算过程不局限于 forward 方法内。实际上,我们需要知道从输入变量 x 到最终 loss 函数计算的完整过程。因此,类似 forward 方法中的计算过程定义,我们会不断遇到。计算过程定义方法,forward 给出了很好的示范。

下面我写一个例子,这个例子中,定义了加法运算。这个加法运算并没有计算两个数字的和,而是把加法计算公式保存成一个字符串。代码如下:

class MyData:
    data=""
    def __init__(self, x):
        self.data = x
    def __add__(self, other):
        return self.data + ' + ' + other.data
a = MyData('a')
b = MyData('b')
c = a + b
print(c)
----------------------------------------------

a + b

Pytorch 就是利用这样的机制,把我们习以为常的计算公式转换成了内部定义格式,供其他函数解释执行。

那么,前面的代码中,变量 x、y 等都是些什么类型呢?在 Pytorch 中,神经网络的输入和输出都是张量。也就是 torch.Tensor 类型。这种类型重载的数学运算符,因此我们可以用常规的计算公式把公式定义保存到网络模型里。

也正因为 Python 语言的这个特点,使得它能够担当“元语言”的角色,在抽象层面定义复杂计算过程,以便开发出高级的数学计算工具。Pytorch、Tensorflow、Keras 都充分利用了运算符重载机制,描述算法模型的计算过程。

3. Pytorch 的梯度(微分)计算机制

神经网络的训练离不开微分计算。Pytorch 既然能够提供计算过程的定义机制,也就是说 Pytorch 是知道计算过程的公式结构的。既然这样,求解复杂公式的微分也是可能的。我们先看个例子:

3.1 微分计算机制

x = torch.tensor([1.0], requires_grad = True)
print("x = ", x)
y = x*x
print("y = ", y)
y.backward()
print("x.grad = ", x.grad)
----------------------------------------------

x =  tensor([1.], requires_grad=True)
y =  tensor([1.], grad_fn=<MulBackward0>)
x.grad =  tensor([2.])

下面解释一下代码:

  • x 是一个张量,我们定义的时候要求保存梯度信息:x = tensor([1.], requires_grad=True)
  • y 等于 x 的平方。当然,y = x * x 应该是借助运算符重载机制,把这个计算公式转化成内部的数据结构保存起来了。因此我们可以断定,变量 y 不仅保存了 1*1 的值,而且也保存的计算公式本身。从后面的打印结果可以证实这一结论:tensor([1.], grad_fn=<MulBackward0>)
  • y.backward() 会对其携带的公式计算微分。这个过程很神奇,无论计算过程有多少步骤,backward 都能够回溯到 y 所依赖的变量 x。我们看一下 x 的微分:x.grad = tensor([2.])。对照一下下面的数学公式,结果是正确的.

通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

通过两个神经元的极简模型,清晰透视 Pytorch 工作原理
当 x = 1 时,梯度值 通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

3.2 Pytorch 中梯度(微分)累加机制

上面的例子修改一下:

x = torch.tensor([1.0], requires_grad = True)
print("x = ", x)
y = x*x
print("y = ", y)
y.backward()
print("x.grad = ", x.grad)
y = x*x
print("y = ", y)
y.backward()
print("x.grad = ", x.grad)
x.grad = torch.tensor([0.])
print("x.grad = ", x.grad)
----------------------------------------------

x =  tensor([1.], requires_grad=True)
y =  tensor([1.], grad_fn=<MulBackward0>)
x.grad =  tensor([2.])
y =  tensor([1.], grad_fn=<MulBackward0>)
x.grad =  tensor([4.])
x.grad =  tensor([0.])

注意,x.grad 的值是累加的。这一特点很重要,因为当用大量训练样本训练时,我们需要累计每一个样本的梯度。

4. Pytorch 官网提供的梯度(微分)计算的例子

由于微分计算非常重要,这里再展示一个更复杂的微分计算的例子,来自 https://www.pytorch123.com

import torch
x = torch.ones(2, 2, requires_grad=True)
y = x + 2
z = y * y * 3
out = z.mean()
out.backward()
print(x.grad)
----------------------------------------------

tensor([[4.5000, 4.5000],
        [4.5000, 4.5000]])

这个展示了多维输入和多维输出网络的微分计算,微分公式不再是一个简单的代数式,而是一个矩阵,该矩阵就是多元函数微积分里著名的雅可比矩阵。具体细节不多讲了,反正记住这个矩阵是训练模型的时候用于反向传播算法的。参见下面的解释。
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

5. 生成训练数据

我们让神经网络模型在 通过两个神经元的极简模型,清晰透视 Pytorch 工作原理 区间内逼近下面的函数:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

基于该函数解析式,用下面的代码生成10组训练数据:

x = torch.linspace(0, 1, 10).reshape(10, 1)
y = x*x - 0.5*x + 1.5625   

其中,x是输入数据,y是训练标签。

6. 构建损失函数

损失函数结果是网络 net 针对输入 xi 预测的结果 net(xi) 与标签 yi 误差的绝对值的和。数学公式如下,其中 通过两个神经元的极简模型,清晰透视 Pytorch 工作原理 是训练样本数量:
通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

由于 Pytorch 中的函数和运算都是针对张量设计的,因此函数定义也很简单:

	loss = sum(abs(net(x) - y))

7. 选择梯度下降的策略

为了更好更快地训练模型,有多种梯度下降策略可以选择。由于不同的策略导致不同的训练代码,为简化这部分工作,Pytorch 提供了优化框架。训练代码如下:

import torch.optim as optim
optimizer = optim.SGD(net.parameters(), lr = 0.001)
for n in range(0, 100000):
    optimizer.zero_grad()
    loss = sum(abs(net(x) - y))
    loss.backward()
    optimizer.step()
    if n % 1000 == 0:
        print(n, loss)
print('Finished Training!')

首先我们选择 optim.SGD (随机梯度下降)作为优化策略,学习率 lr = 0.001。学习率需要具体实验才能确定。如果训练过程中,loss 的值不能稳步下降,而是不断出现徘徊,说明 lr 的值太大了。如果 loss 下降速度很慢,说明 lr 的值太小了。

本例中,我计划迭代 100000 次。

在循环体内,首先执行梯度清零。我们知道,net 的梯度值是不断累加的。在每个迭代开始时,需要清零梯度,以便计算当前模型针对训练样本的总体梯度值。

8. 显示结果

用 matplotlib 的工具先试一下训练效果:

import matplotlib.pyplot as plt
plt.plot(x, y, "k*")

z=[]
x = torch.linspace(0,1,100).reshape(100,1)
for xx in x:
    zz = net(xx)
    z.append(zz)
plt.plot(x, z, "b-")

通过两个神经元的极简模型,清晰透视 Pytorch 工作原理

有了本文的基础,相信进一步学习 Pytorch 应该非常轻松了。

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
乘风的头像乘风管理团队
上一篇 2022年5月28日 下午4:10
下一篇 2022年5月28日

相关推荐