PS:要转载请注明出处,本人版权所有。
PS: 这个只是基于《我自己》的理解,
如果和你的原则及想法相冲突,请谅解,勿喷。
环境说明
- Windows 10
- VSCode
- Python 3.8.10
- Pytorch 1.8.1
- Cuda 10.2
前言
从我2017毕业到现在为止,我的工作一直都是AI在边缘端部署落地等相关内容。所以我的工作基本都集中在嵌入式+Linux+DL三者之内的范围,由于个人兴趣和一些工作安排,就会去做一些模型移植的工作,所以我会经常接触模型基本结构,前处理、后处理等等基本的知识,但是其实我很少去接触模型怎么来的这个问题。虽然以前也硬啃过Lenet5和BP算法,也按照别人弄好的脚本训练过一些简单的模型,但是从来没有认真仔细的看过这些脚本,这些脚本为什么这样写。
在2019年下半年,随着我移植模型的工作深入,接触的各种硬件平台越来越多,经常遇见一些层在此平台无法移植,需要拆出来特殊处理。这让我产生了为啥这些层在这些特定平台不能够移植的疑问?为啥替换为别的层就能正常工作?为啥此平台不提供这个层?于是我去请教我们的算法小伙伴们,他们建议我如果要解决这个问题,建议我学习一下DL的基本知识,至少要简单了解从数据采集及处理、模型搭建及训练、模型部署等知识,其中模型部署可能是我最了解的内容了。利用一些闲暇时间和工作中的一些机会,我对以上需要了解的知识有了一个大概的认知。随着了解的深入,可能我也大概知道我比较缺一些基础知识。经过小伙伴的推荐和自己的搜索,我选择了《动⼿学深度学习.pdf》作为我的基础补全资料。
本文是以‘补全资料’的Chapter3中线性规划为基础来编写的,主要是对‘补全资料’之前的基础知识的一个简单汇总,包含了深度学习中一些基本的知识点,同时会对这些基本知识点进行一些解释。
由于我也是一个初学者,文中的解释是基于我的知识水平结构做的‘特色适配’,如果解释有误,请及时提出,我这里及时更正。写本文的原因也是记录我的学习历程,算是我的学习笔记。
回归概念
回归是一种建模方法,得到的模型表示了自变量和因变量的关系。因此回归还可以解释为一种事与事之间的联系。对我们来说最常见的例子就是我们学习过的函数。例如函数Y=aX+b,这里的Y=aX+b就是我们模型, X代表自变量,Y代表因变量。
这里顺便多说一句,深度学习是机器学习的子集。建模方法很多,回归只是其中的一种。
线性回归
线性回归就是自变量和因变量是线性关系,感觉跟废话一样,换个方式表达就是自变量是一次。例如:Y=aX+b, Y=aX1+bX2+c, 这里的X、X1、X2都是一次的,不是二次或者更高的。
非线性回归
非线性回归就是自变量和因变量不是是线性关系,同样感觉跟废话一样,换个方式表达就是自变量是二次及以上的。这里和线性回归对比一下就行。
基于y=aX1^2 + bX1^2 +cX2+dX2+e的回归Pytorch实例
此实例是《动⼿学深度学习.pdf》中线性回归的从零实现的变种。从零实现,可以了解到许多的基本知识。
此小节基本按照数据采集及处理、模型搭建及训练和模型部署来描述。
带噪声数据采集及处理
我们知道,在我们准备数据时,肯定由于各种各样的原因,会有各种干扰,导致我们根据实际场景采集到的数据,其实不是精准的,但是这并不影响我们建模,因为我们的模型是尽量去拟合真实场景的情况。
生成我们的实际模型的噪声数据,也就是我们常见的数据集的说法。如下是代码:
import numpy as np
def synthetic_data(w1, w2, b, num_examples): #@save
"""⽣成y = X1^2w1 + X2w2 + b + 噪声。"""
# 根据正太分布,随机生成我们的X1和X2
# X1和X2都是(1000, 2)的矩阵
X1 = np.random.normal(0, 1, (num_examples, len(w1)))
X2 = np.random.normal(0, 1, (num_examples, len(w2)))
# 基于X1,X2,true_w1, true_w2, true_b, 通过向量内积、广播等方法计算模型的真实结果
y = np.dot(X1**2, w1) + np.dot(X2, w2) + b
# 通过随机噪声加上真实结果,生成我们的数据集。
# y是(1000, 1)的矩阵
y += np.random.normal(0, 0.1, y.shape)
return X1, X2, y.reshape((-1, 1))
true_w1 = np.array([5.7, -3.4])
true_w2 = np.array([4.8, -3.4])
true_b = 4.2
# 这里我们得到了我们的数据集,包含了特征和标签
features1, features2, labels = synthetic_data(true_w1, true_w2, true_b, 1000)
# 因为我们知道我们的模型是y=aX1^2+bX1^2+cX2+dX2+e,于是我们知道a的数据分布是类似y=ax^2+b的这种形状。于是我们知道c的数据分布是类似y=ax+b的这种形状。
# 我们可以通过如下代码验证一下
plt.scatter(features1[:, 0], labels[:], 1, c='r')
plt.scatter(features2[:, 0], labels[:], 1, c='b')
plt.show()
同时这里我们还要准备一个函数用来随机抽取特征和标签,一批一批的进行训练。
def data_iter(batch_size, features1, features2, labels):
num_examples = len(features1)
indices = list(range(num_examples))
np.random.shuffle(indices) # 样本的读取顺序是随机的
for i in range(0, num_examples, batch_size):
j = np.array(indices[i: min(i + batch_size, num_examples)])
# print(features1.take(j, axis=0).shape)
yield torch.tensor(features1.take(j, 0)), torch.tensor(features2.take(j, 0)), torch.tensor(labels.take(j)) # take函数根据索引返回对应元素
模型搭建及训练
对于我们这个实例来说,模型就是一个二元二次函数,此外这里的模型也叫作目标函数。所以,下面我们用torch来实现它就行。
def our_func(X1, X2, W1, W2, B):
# print(X1.shape) (100, 2)
# print(W1.shape) (2, 1)
net_out = torch.matmul(X1**2, W1) + torch.matmul(X2, W2) + B
# print(net_out.shape) (100, 1)
return net_out
注意哟,这里的X1,X2,W1,W2,B都是torch的tensor格式。
由数据采集及处理可知,在我们设定的真实true_w1,true_w2和true_b的情况下,我们得到了许许多多的X1,X2和y。我们要做的事情是求出W1、W2和b,这里看起是不是很矛盾?我们已知了true_w1,true_w2,true_b然后去求w1,w2,b。这里其实是一个错觉,由于在实际情况中,我们可能会得到许许多多的X1,X2和y,这些数据不是我们模拟生成的,而是某种关系实际产生的数据,只是这些数据被我们收集到了。在实际情况下,而且我们仅仅只能够得到X1,X2和y,我们通过观察X1,X2和y的关系,发现他们有一元二次和一元一次关系,所以我们建立的模型为y=aX12+bX12+cX2+dX2+e,这个过程称为建模。
通过上面的说明,我们知道这个模型我们要求a,b,c,d,e这些参数的值,我们求这些参数的过程叫做训练。对于这个实例来说,这个过程也叫作求解方程,这里其实我们可以通过解方程的方法把这5个参数解出来,但是在实际情况中,我们建立的模型可能参数较多,可能手动解不出来,于是我们要通过训练的方式,去拟合这些参数。所以这里一个重要的问题就是怎么拟合这些参数?
如果学习过《数值分析》这门课程的话,其实就比较好理解了,如果要拟合这些参数,我们有许多的方法可以使用,但是基本分为两类,一类是针对误差进行分析,一类是针对模型进行分析。那么在深度学习中,我们一般是对误差进行分析,也就是我们常说的梯度下降法,我们需要随机生成这些参数初始值(w1,w2,b),然后根据我们得到的X1,X2和y,通过梯度下降方法可以得到新的w1’,w2’,b’,且w1’,w2’,b’更加接近true_w1,true_w2和true_b。这就是一种优化过程,经过多次优化,我们就有可能求出跟接近与true_w1,true_w2和true_b的值。
上面啰嗦了一大堆之后,我这里正式引入损失函数这个概念,然后我们根据损失函数去优化我们的w1,w2,b。我们定义这个实例的损失函数如下:
def loss_func(y_train, y_label):
# print(y_train.shape)
# print(y_label.shape)
return ((y_train - y_label)**2)/2
这里我们y_train就是我们得到的训练值,y_label就是我们采集到数值,这里的损失函数就是描述训练值和标签的差多少,那么我们只需要让这个损失函数的值越来越小就行,那么可能问题就变成了求损失函数的极小值问题,我们在这里还是用梯度下降的方法来求损失函数的极小值。
这里要回忆起来一个概念,梯度是一个函数在这个方向增长最快的方向。我们求损失函数的极小值的话,就减去梯度就行了。
这里还要说一句,损失函数有很多(L1,MSE,交叉熵等等),大家以后可以自己选一个合适的即可,这里的合适需要大家去学习每种损失函数的适用场景。这里的平方损失的合理性我个人认为有两种:
- 1 直观法,平方损失函数描述的是在数据集上,训练值和真实值的误差趋势,我要想得到的参数最准确,就要求误差最小,误差最小就要求我去求解损失函数的极小值,这是我所了解的数学知识的直观反映。 (直觉大法)
- 2 数学证明如下:我们生成数据或者采样数据的时候,他们的误差服从正态分布( P ( x ) = 1 / 2 π σ 2 ∗ e x p ( ( − 1 / 2 σ 2 ) ( x − μ ) 2 ) P(x) = 1/\sqrt{2\pi\sigma^2}*exp((-1/2\sigma^2)(x-\mu)^2) P(x)=1/2πσ2 ∗exp((−1/2σ2)(x−μ)2) ),于是我们根据条件概率得到特定feature得到特定y的概率为: P ( y ∣ X ) = 1 / 2 π σ 2 ∗ e x p ( ( − 1 / 2 σ 2 ) ( y − w 1 X 1 2 − w 2 X 2 − b ) 2 ) P(y|X) = 1/\sqrt{2\pi\sigma^2}*exp((-1/2\sigma^2)(y-w1X1^2-w2X2-b)^2) P(y∣X)=1/2πσ2 ∗exp((−1/2σ2)(y−w1X12−w2X2−b)2),根据最大似然估计 P ( y ∣ X ) = ∏ i = 0 n − 1 P ( y i ∣ X i ) P(y|X) = \prod\limits_{i=0}^{n-1}P(y^{i}|X^{i}) P(y∣X)=i=0∏n−1P(yi∣Xi),此时最大似然估计最大,w1,w2,b就是最佳的参数,但是乘积函数的最大值不好求,我们用对数转换一下,变为各项求和,只需要保证含义一致就行。由于一般的优化一般是说最小化,所以我们要取负的对数和 − log ( P ( y ∣ X ) ) -\log(P(y|X)) −log(P(y∣X)),此时我们把最大似然估计函数转换为对数形式: − log ( P ( y ∣ X ) ) = ∑ i = 0 n − 1 1 / 2 log ( 2 π σ 2 ) + 1 / 2 σ 2 ( y − w 1 X 1 2 − w 2 X 2 − b ) 2 ) -\log(P(y|X)) = \sum\limits_{i=0}^{n-1}1/2\log(2\pi\sigma^2)+1/2\sigma^2(y-w1X1^2-w2X2-b)^2) −log(P(y∣X))=i=0∑n−11/2log(2πσ2)+1/2σ2(y−w1X12−w2X2−b)2),我们可以看到要求此函数的最小值,其实就是后半部分的平方是最小值就行,因为其余的都是常数项,而后面部分恰好就是我们的平方损失函数。(Copy书上大法)
这里的梯度下降法就是(w1,w2,b)-lr*(w1,w2,b).grad/batch_size,代码如下:
def sgd(params, lr, batch_size): #@save
"""⼩批量随机梯度下降。"""
for param in params:
with torch.no_grad():
param[:] = param - lr * param.grad / batch_size
这里我画出我们的损失函数的三维图像,我们把损失函数简化为Z=aX^2+bY+c的形式。其图像如下图:
我们可以看到这个曲面有很多极小值,当我们随机初始化a,b,c的时候,我们会根据X和Y得到部分损失点,这时我们求出a,b,c的梯度,当我们对a,b,c减去偏导数时,就会更快的靠近损失函数的谷底,我们的当我们的损失函数到极小值时,我们就认为此时的a,b,c是我们要找的参数。这就是我们的梯度下降法的意义所在。
下面就直接写训练代码就行。
w1 = torch.tensor(np.random.normal(0, 0.5, (2, 1)), requires_grad=True)
w2 = torch.tensor(np.random.normal(0, 0.5, (2, 1)), requires_grad=True)
b = torch.tensor(np.ones(1), requires_grad=True)
# print(w1.grad)
# print(w1.grad_fn)
#
lr = 0.001
num_epochs = 10000
net = our_func
loss = loss_func
batch_size = 200
for epoch in range(num_epochs):
for X1, X2, y in data_iter(batch_size, features1, features2, labels):
# print(X1.shape) (100, 2)
# print(y.shape) (100, 1)
l = loss(net(X1, X2, w1, w2, b), y.reshape(batch_size, 1)) # `X`和`y`的⼩批量损失
# print(f'epoch {epoch + 1}, before sdg loss {float(l.mean()):f}')
# 计算l关于[`w`, `b`]的梯度
# print(l.shape)
# l.backward() default call, loss.backward(torch.Tensor(1.0))
w1.grad = None
w2.grad = None
b.grad = None
l.backward(torch.ones_like(y.reshape(batch_size, 1)))
sgd([w1, w2, b], lr, batch_size) # 使⽤参数的梯度更新参数
l1 = loss(net(X1, X2, w1, w2, b), y.reshape(batch_size, 1)) # `X`和`y`的⼩批量损失
# print(f'epoch {epoch + 1}, after sdg loss {float(l1.mean()):f}')
train_l = loss(net( torch.from_numpy(features1), torch.from_numpy(features2), w1, w2, b), torch.from_numpy(labels))
# print(train_l.sum())
if (epoch % 1000 == 0):
print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')
print('train_w1 ',w1)
print('train_w2 ',w2)
print('train_b ',b)
这里做的事情就是首先设定训了次数,学习率,批次数量参数,然后随机生成了w1,w2,b,然后通过data_iter取一批数据,算出loss,清空w1,w2,b的梯度,对loss求w1,w2,b的偏导数,调用sdg求新的w1,w2,b。最后计算新参数在整个数据集上的损失。
这里尤其需要注意的是在多次迭代过程中,一定要清空w1,w2,b的梯度,因为它的梯度是累加的,不会覆盖。这里用的Pytorch的自动求导模块,其实底层就是调用的bp算法,利用了链式法则,反向从loss开始,能够算出w1,w2,b的偏导数。
我们从上文可知,true_w1 = np.array([5.7, -3.4]),true_w2 = np.array([4.8, -3.4]),true_b = 4.2。我们训练出来的值是w1=(5.7012, -3.3955),w2=(4.8042, -3.4071),b=4.2000。可以看到其实训练得到的值还是非常接近的,但是这个任务其实太简单了。
后记
这里主要用到了许多基本的概念,一个是数据集的收集和处理,模型搭建,模型训练,优化方法、损失函数等等。至少通过本文,我们知道了得到一个模型的基本过程,其实普遍性的深度学习就是使用新的损失函数、搭建新的模型、使用新的优化方法等等。
从文中特例我们可以发现,我们只利用了Pytorch自带的自动求导模块,其他的一些常见的深度学习概念都是我们手动实现了,其实这些好多内容都是Pytorch这些框架封装好的,我们没有必要自己手写,但是如果是初次学习的话,建议手动来写,这样认知更加深刻。
参考文献
- https://github.com/d2l-ai/d2l-zh/releases (V1.0.0)
- https://github.com/d2l-ai/d2l-zh/releases (V2.0.0 alpha1)
PS: 请尊重原创,不喜勿喷。
PS: 要转载请注明出处,本人版权所有。
PS: 有问题请留言,看到后我会第一时间回复。