前言
上一篇内容:入门级深度神经网络 with Pytorch(1) - 从MNIST开始
在上一篇内容中,我们主要介绍了如何用Pytorch来实现一个神经网络中的HelloWorld——基于MNIST的手写体数字识别。由于主要是想要给大家一个使用pytorch的概念和感觉,所以在理论上难免有些隔靴搔痒之感。
所以从本篇开始,我会从最简单的理论开始给大家一步步讲解神经网络,即使你没有任何人工智能的理论基础,也能通过这篇文章窥得入门的办法。当然,在理论之后我还会提供代码的实践部分,毕竟对于开发者来说,脱开源码谈理论就是耍流氓。那话不多说,我先来介绍一下今天要讲的内容,线性网络层。
代入场景
对于绝大多数的神经网络结构,我们必然会看到这么个东西。
nn.Linear(128, 10)
如果我现在给你解释:“这个是神经网络层的一种,叫做线性网络层,或者叫它隐藏层、全连接层。它本质上内部是一个线性函数y=kx+b,用于拟合一个线性的过程。”这么说好像完全说不明白。想来想去,我们还是从代入一个特定的场景来解释这个东西。
现在想象一下你穿越到了一个淘金时代,在这片大陆上,有着非常多的金矿。你挖了大大小小很多金矿石,想要去卖钱。直到卖钱的时候你才意识到每块被开才出来的金矿石,里面由于含有杂质,你在挖出来的时候并不能一下子就直接根据重量得到价格。
不过好消息是,从整体上来说,每块金矿石里含有的杂质都差不多,整体上来看,金矿石越重,能卖的价钱就越高。
你希望凭借着这份数据,在下次淘金的时候能提前就能预测出你挖出来的每一块矿石大概能卖多少钱。也就是说你希望有一个办法可以表达出重量和价钱的关系,以方便你来做出预测
理论
前向传播
当我们看到上图的数据的时候,凭借着最质朴的直觉,我们应该知道重量和价钱的关系,大致上可以通过一条过原点的直线来表达,也即y = w*x。但w到底是多少,你却一时半会儿无法确定,于是你便胡乱猜了个w=5,即y = 5x。
你你拿出了一块石头带入计算了一下,重量为50.2,根据你的w算出来的y_predict = 5 * 50.2 = 251。也即你预测出的结果为251。
如果你还记得上一篇MNIST中的训练过程的话,没错,你已经完成了一次前向传播,并得到了前向传播的结果。
损失计算
为了验证你的猜想,你看了下这块石头的数据,重量为50.2,价格为1517。完犊子,预测出来的跟正确答案也差太多了。于是你又多取了几块石头来验证,发现它们的实际价格和预测价格都差了很多
你希望可以量化一下你的预测结果到底差了多少,你翻了翻一些资料,发现了一个很不错的计算方式——差方,即:
loss = (y - y_predict)**2
而你所有的石头的预测结果,和它们实际结果的差方的均值,便可认为是你预测的w的一个平均误差。这即是一种较为简单的损失计算方式。
梯度下降
既然以及选择了一个比较靠谱的损失计算方法,那我们下一个目标就是要想办法让这个损失尽可能地小。
我们再拿出最初用来测试的石头,我们将重量50.2记为x0,价格1517记为y0,我们现在可以构造出一个输出为损失值,输入为w的函数:
这很显然,是一个关于w的一元二次方程,画出来即是一个开口向上的抛物线:
上图即为带入x0=50.2,y0=1517时的抛物线。
上过初中的同学都知道,如果要求y的最小值,即是求这个抛物线底部的顶点。根据抛物线顶点公式:
w
=
−
b
2
a
w = -\frac{b}{2a}
w=−2ab
a
=
x
0
2
=
50.
2
2
a = x_{0}^{2}=50.2_{}^{2}
a=x02=50.22
b
=
−
2
x
0
y
0
=
−
2
∗
50.2
∗
1517
b=-2x_{0}y_{0}=-2*50.2*1517
b=−2x0y0=−2∗50.2∗1517
很容易求得w即为30.219。那我是不是把这个w代入进去就ok了?好像确实没错。但实际上我们忽略了一个很重要的点,这个值仅仅是从一个随便拿出来的样本算出来的。我们的样本集本身如果比较理想,那倒是误差也不会太大。但如果样本本身分布不均匀,或者有些骨骼惊奇,不按套路出牌的样本的话,问题就会很大了。
我们上一节也说了,我们的损失其实是所有样本的预测结果和实际结果的差方的均值,所以我们的损失函数其实是这样的:
G
l
o
s
s
=
(
x
0
2
+
x
1
2
+
.
.
.
+
x
n
2
)
w
0
2
−
2
(
x
0
y
0
+
x
1
y
1
+
.
.
.
+
x
n
y
n
)
w
0
+
(
y
0
2
+
y
1
2
+
.
.
.
+
y
n
2
)
n
G_{loss}=\frac{(x_{0}^{2}+x_{1}^{2}+...+x_{n}^{2})w_{0}^{2}-2(x_{0}y_{0}+x_{1}y_{1}+...+x_{n}y_{n})w_{0} + (y_{0}^{2} + y_{1}^{2} + ... + y_{n}^{2})}{n}
Gloss=n(x02+x12+...+xn2)w02−2(x0y0+x1y1+...+xnyn)w0+(y02+y12+...+yn2)
可以看到,它仍旧是一个关于w的开口向上的抛物线。那有些聪明的同学就会想,这个时候求它的顶点坐标公式是不是就ok了呢?
答案还是不行。原因有两点:
- 我们很多情况下,会有上百万甚至上亿的数据,给这么多数据去做加乘运算对性能的开销是很恐怖的
- 说到底,这样的结果是对所有训练样本的最精确的,但不一定对其他的数据有普适性,也就是会过拟合。
那么我们到底该怎么才能把损失降低呢?前人给了我们一个很棒的思路,那就是每次让损失减小一点,然后多遍历几次。我先从100块石头中拿出10块(一个batch),拿着这10块石头我便能算得由它们差方均值构造出的抛物线。这时候我通过减小或增大一定量的w值,让w去更接近抛物线的底部一点。然后再拿出10块石头,得到抛物线,把上一步算出的w在当前这条抛物线上,再往底部接近一点……如此重复,直到所有石头都被取出一遍,我们便完成了一次训练(一个epoch)。
这样做,既能让损失不断地在变小,又可以兼顾性能,不至于让我们的硬件被打爆,非常地完美。
最后的问题,就是到底我们要怎么增减w的值,才能让它去接近抛物线的底部呢。我们来看下面这张图,这是抛物线和其上某一点对应的切线:
w=5
w=8
w=10
我们观察这条切线的斜率,我们在抛物线上取的点约接近底部,我们的切线的斜率就越接近0。随着我们向顶点移动w,斜率是逐次下降的。这便是我们一直所听到的梯度下降。之所以叫梯度下降而不是叫斜率下降,是因为我们目前仅在一个很低的维度举了这个例子,实际上当数据并非一元,而是N元时,梯度是一个更为宽泛的能指代这件事情的概念,在此我们并不展开。
很显然我们就会想到一个很不错的梯度下降的办法,那就是每次我们优化我们的w值的时候,都让w减去当前的斜率,这样一来当我们约接近底部的时候,斜率便越小,减去的值也便越小,这样就可以大体上是在向底部接近了。
但是如果只是单纯去减斜率,很可能会减过头。我们凭借我们的几何直觉,很容易发现如果一次性把w移动太多,那便会移动过了顶点,并且很可能过去很多,导致斜率的绝对值变得更大了,比如原本斜率k=1000,减完以后一算,k=-10000,那就崩了。为了阻止这种情况发生,我们需要给减去的斜率乘以一个权重,我们在这里称它为alpha。于是我们每次梯度下降的公式便可以得出了:
w
n
+
1
=
w
n
−
a
l
p
h
a
∗
k
n
w_{n+1}=w_{n}-alpha*k_{n}
wn+1=wn−alpha∗kn
k
n
=
2
∗
a
∗
w
n
+
b
k_{n}=2*a*w_{n}+b
kn=2∗a∗wn+b
w
n
+
1
=
w
n
−
a
l
p
h
a
∗
(
2
∗
a
∗
w
n
+
b
)
w_{n+1}=w_{n}-alpha*(2*a*w_{n}+b)
wn+1=wn−alpha∗(2∗a∗wn+b)
其中:
a
=
x
0
2
+
x
1
2
+
.
.
.
+
x
m
2
a=x_{0}^{2}+x_{1}^{2}+...+x_{m}^{2}
a=x02+x12+...+xm2
b
=
−
2
∗
(
x
0
y
0
+
x
1
y
1
.
.
.
+
x
m
y
m
)
b=-2*(x_{0}y_{0} +x_{1}y_{1}...+x_{m}y_{m})
b=−2∗(x0y0+x1y1...+xmym)
注意,这里每次的x0~xm,即是一个batch中所拿出的数据,上面的这个n->n+1的步骤,即为当前batch所做的一步梯度下降。
这里的alpha便是我们一直所听到的学习率了。
反向传播
好了,现在我们得到了我们新的w,我们在这里,就把我们一开始所定下的就w抹掉,换成我们新的w,就完成了一次反向传播,更新了我们的权重参数。
接着,我们便可以继续重复地进行前向传播->损失计算->梯度下降->反向传播,这样的循环,直到我们的损失越来越少,w的值越来越精确。我们便能更好地估计下一次开采出来的金矿石的价钱了。
这便是一个最简单的线性网络层里的一个网络节点所做的所有的事情。注意,这只是最最最基本的一环,我们忽略了一个很重要的点,如果要完整地描述一条直线,公式是这样的:
y
=
w
∗
x
+
b
y=w*x+b
y=w∗x+b
我们上面的这些,都是再直线过原点,没有截距的前提下计算出的。下一章我们便会去讨论,有截距b的情况下,我们的训练过程要何去何从。
代码实践
在我们程序员界有句古话,叫做识时务者为俊杰 Talk is cheap, show me the code。让我们直接用Pytorch,来把上面的这些理论转化成代码来消化一下吧。
训练数据构造
首先我们来构造一下我们的训练数据,即金矿石的重量和对应的价格。这里我们引入一些随机值,来让生成的数据大体上是线性分布的,但会有一定的随机散列。
import numpy as np
import random
def get_data(num):
xs = []
ys = []
weight = 30
for i in range(num):
# 引入一些随机范围,让x和y在小范围内随机震荡
x = i + random.randint(-4, 4) / 10
y = (weight) * x + random.randint(-150, 150)
xs.append(x)
ys.append(y)
X = np.array(xs)
Y = np.array(ys)
return X, Y
我们用一个matplotlib库来在一个坐标轴中打印一下我们生成的点。这个库是一个很方便地可以用来做图的库,如果没有用过的同学在命令行中如此安装即可:
pip install matplotlib
代码和结果如下:
from matplotlib import pyplot as plt
X, Y = get_data(100)
plt.title("Gold Hunter", fontsize=12)
plt.xlabel("Weight")
plt.ylabel("Price")
plt.scatter(X, Y)
plt.show()
构造好了数据集,我们把它们给放入tensor中去:
import torch
train, result = torch.FloatTensor(X), torch.FloatTensor(Y)
神经网络构建
接下来我们要构建我们的神经网络了,我们这次要做的很简单,网络层每次只接受一个数据,并输出一个数据,然后即进行梯度下降。
import torch.nn as nn
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
# 构造一个出入皆为1维的线性层
self.lc = nn.Linear(1, 1)
def forward(self, x):
output = self.lc(x)
return output
结构很简单,我们只加了一个线性层进去,并指定了出入参。接着我们便可直接初始化我们的网络和优化器。在这里我们使用SGD优化器,即随机梯度下降优化器,该优化器基本原理和我们上述的是一致的。
我们还需要指定我们的损失函数,这里使用MSELoss损失函数,也即我们上面提到的均差方损失函数。
import torch.optim as optim
from torch.nn import MSELoss
# 在这里,我们设学习率为0.00001
learning_rate = 1e-5
model = Net()
optimizer = optim.SGD(model.parameters(), lr=learning_rate)
loss_function = MSELoss()
开始训练
训练过程很简单,我们每次取出一个数据,让它通过神经网络做前向传播,然后跟我们理论部分一样,计算损失->梯度下降->反向传播,然后使用下一个数据继续优化我们的参数。在这里我们把每一步梯度下降完的w值进行保存,到最后看看我们得到的w是多少(按照数据构造的规律,应该要接近30才是正确的)。
final_w = 0
for i in range(100):
# 取出训练数据
data = train[i:i+1]
target = result[i:i+1]
# 清空上步已存的梯度(按照理论部分的介绍思考一下,我们其实每步本身都是重新计算梯度的)
optimizer.zero_grad()
# 前向传播
output = model(data)
# 计算损失
loss = loss_function(output, target)
# 梯度下降 && 反向传播
loss.backward()
optimizer.step()
# 从最新的模型参数中取出我们的w权重值
grad = 0
for parameters in model.parameters():
grad = parameters
break
final_w = grad.data[0][0]
验证结果
最后,我们将得到的final_w也用matplotlib答应出来,看看直线拟合的结果:
y_pre = final_w * X
plt.plot(X, y_pre)
plt.show()
芜湖~看起来拟合的非常完美,至此,我们便完成了一次线性网络节点的代码实践。
结语
我们回顾一下,在这篇文章中,我们假想了一个挖金矿估值的场景,通过这个场景向大家介绍了什么是线性神经网络,并让大家对训练模型中的各个环节有了一个比较具象化的认识。
说实话,但看本文并没有任何实战方面的意义。它仅给大家介绍了我们庞大复杂的深度神经网络中最最最最最基本的一个组成——线性网络。在当今神经网络的蓬勃发展中,我们还需要有更多更多的知识才能真正地很好地去使用它。
但这篇文章提到的点又很重要,前向传播、反向传播、梯度下降,这些概念,我们如果不从最简单的结构开始去理解它们,那对这些非常关键的训练环节我们始终会有些不踏实。
在之后的文章中,我会继续迭代我们的“金矿估值助手”,让它能处理更多的场景。下一篇我们便会扩展我们本次介绍的线性网络,让其能处理带有截距的线性场景。
完整代码
import numpy as np
import random
import torch
from matplotlib import pyplot as plt
import torch.nn as nn
import torch.optim as optim
from torch.nn import MSELoss
batch_size = 1
learning_rate = 1e-5
def get_data(num):
xs = []
ys = []
weight = 30
for i in range(num):
x = i + random.randint(-4, 4) / 10
y = (weight) * x + random.randint(-150, 150)
xs.append(x)
ys.append(y)
X = np.array(xs)
Y = np.array(ys)
return X, Y
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.lc = nn.Linear(batch_size, batch_size)
def forward(self, x):
output = self.lc(x)
return output
if __name__ == "__main__":
X, Y = get_data(100)
plt.title("Gold Hunter", fontsize=12)
plt.xlabel("Weight")
plt.ylabel("Price")
plt.scatter(X, Y)
train, result = torch.FloatTensor(X), torch.FloatTensor(Y)
model = Net()
optimizer = optim.SGD(model.parameters(), lr=learning_rate)
loss_function = MSELoss()
final_w = 0
for i in range(100):
data = train[i:i+1]
target = result[i:i+1]
optimizer.zero_grad()
output = model(data)
loss = loss_function(output, target)
loss.backward()
optimizer.step()
grad = 0
for parameters in model.parameters():
grad = parameters
break
final_w = grad.data[0][0]
y_pre = final_w * X
plt.plot(X, y_pre)
plt.show()