大家好。今天想和大家分享一个非常强大的开源项目。我用Numpy开发了一个深度学习框架,语法和Pytorch基本一致。今天以一个简单的卷积神经网络为例,分析神经网络训练过程中涉及的前向传播、反向传播、参数优化等核心步骤的源码。使用的数据集和代码已经打包,获取方式在文末。1.准备工作首先准备好数据和代码。1.1搭建网络首先下载框架源码,地址:https://github.com/duma-repo/PyDyNetgitclonehttps://github.com/duma-repo/PyDyNet.git搭建一个LeNe??t卷积神经网络网络并训练三类模型。只需直接在PyDyNet目录中创建代码文件即可。frompydynetimportnnclassLeNet(nn.Module):def__init__(self):super().__init__()self.conv1=nn.Conv2d(1,6,kernel_size=5,padding=2)self.conv2=nn.Conv2d(6,16,kernel_size=5)self.avg_pool=nn.AvgPool2d(kernel_size=2,stride=2,padding=0)self.sigmoid=nn.Sigmoid()self.fc1=nn.Linear(16*5)*5,120)self.fc2=nn.Linear(120,84)self.fc3=nn.Linear(84,3)defforward(self,x):x=self.conv1(x)x=self.sigmoid(x)x=self.avg_pool(x)x=self.conv2(x)x=self.sigmoid(x)x=self.avg_pool(x)x=x.reshape(x.shape[0],-1)x=self.fc1(x)x=self.sigmoid(x)x=self.fc2(x)x=self.sigmoid(x)x=self.fc3(x)returnx可以看出,定义该网络与Pytorch的语法完全相同。在我提供的源码中,提供了summary功能来打印网络结构。1.2准备数据训练数据使用Fanshion-MNIST数据集,包含10类图片,每类6k张图片。为了加快训练速度,我只提取了前3类,一共1.8w张训练图片,做了一个三类模型。1.3模型训练importpydynetfrompydynetimportnnfrompydynetimportoptimlr,num_epochs=0.9,10optimizer=optimize.SGD(net.parameters(),lr=lr)loss=nn.CrossEntropyLoss()forepochinrange(num_epochs):net.train()fori,(X,y)inenumerate(train_iter):optimizer.zero_grad()y_hat=net(X)l=loss(y_hat,y)l.backward()optimizer.step()withpydynet.no_grad():metric.add(l.numpy()*X.shape[0],accuracy(y_hat,y),X.shape[0])训练代码也和Pytorch一样。接下来要做的重点是深入模型训练的源码,学习模型训练的原理。2.在train之前,no_grad和eval模型开始训练,net.train会被调用。deftrain(self,mode:bool=True):set_grad_enabled(mode)self.set_module_state(mode)可以看到,它会将grad(gradient)设置为True,后面创建的Tensor可以有梯度。Tensor有了梯度后,会被放入计算图中,等待导数计算梯度。以及以下带有no_grad()的代码:codeclassno_grad:def__enter__(self)->None:self.prev=is_grad_enable()set_grad_enabled(False)会将grad(gradient)设置为False,这样之后创建的Tensor将not会放在计算图中,自然就不用计算梯度了,可以加快推理速度。我们在Pytorch中经常看到net.eval()的用法,也顺便看看它的源码。defeval(self):returnself.train(False)可以看到,直接调用train(False)关闭梯度,效果和no_grad()类似。所以一般在训练前调用train开启梯度。训练结束后调用eval关闭梯度,方便快速推理。3.前向传播除了计算类别概率外,前向传播中最重要的是将网络中的张量按照前向传播的顺序组织成计算图。目的是计算反向传播过程中每个张量的梯度。.在神经网络中,张量不仅用于存储数据,还用于计算和存储梯度。以第一层卷积操作为例,看看如何生成计算图。defconv2d(x:tensor.Tensor,kernel:tensor.Tensor,padding:int=0,stride:int=1):'''二维卷积函数'''N,_,_,_=x.shapeout_channels,_,kernel_size,_=kernel.shapepad_x=__pad2d(x,padding)col=__im2col2d(pad_x,kernel_size,步幅)out_h,out_w=col.shape[-2:]col=col.transpose(0,4),5,1,2,3).reshape(N*out_h*out_w,-1)col_filter=kernel.reshape(out_channels,-1).Tout=col@col_filter返回out.reshape(N,out_h,out_w,-1).transpose(0,3,1,2)x为输入图像,不需要记录梯度。kernel是卷积核的权重,需要计算梯度。因此,pad_x=__pad2d(x,padding)生成的新张量也没有梯度,所以不需要添加到计算图中。kernel.reshape(out_channels,-1)生成的张量需要计算梯度,也需要添加到计算图中。看一下加入过程:defreshape(self,*new_shape):returnreshape(self,new_shape)classreshape(UnaryOperator):'''Tensorshapetransformationoperator,overloadedinTensorParameters----------new_shape:tupleTransformedshape,用法同NumPy'''def__init__(self,x:Tensor,new_shape:tuple)->None:self.new_shape=new_shapesuper().__init__(x)defforward(self,x:Tensor)returnx.data.reshape(self.new_shape)defgrad_fn(self,x:Tensor,grad:np.ndarray)returngrad.reshape(x.shape)重塑函数将返回一个reshape类对象,reshape类继承UnaryOperator类,在__init__函数中,调用父类初始化函数。类UnaryOperator(Tensor):def__init__(self,x:Tensor)->None:ifnotisinstance(x,Tensor):x=Tensor(x)self.device=x.devicesuper().__init__(data=self.forward(x),device=x.device,#这里requires_grad为Truerequires_grad=is_grad_enable()andx.requires_grad,)UnaryOperatorclass继承Tensorclass,所以reshapeobject也是张量。在UnaryOperator的__init__函数中,调用了Tensor的初始化函数,传入的required_grad参数为True,表示需要计算梯度。requires_gradis_grad_enable()和x.requires_grad的计算代码,is_grad_enable()已经被train设置为True,而x为卷积核,其requires_grad也为True。classTensor:def__init__(self,data:Any,dtype=None,device:Union[Device,int,str,None]=None,requires_grad:bool=False,)->None:ifself.requires_grad:#不需要梯度计算的节点不出现在动态计算图中Graph.add_node(self)最后在Tensor类的初始化方法中,调用Graph.add_node(self)将当前张量添加到计算图中。同样的,使用下面requires_grad=True的tensor的新tensor会放到计算图中。经过一次卷积运算后,计算图中会增加6个节点。4.反向传播正向传播完成后,从计算图中的最后一个节点开始,从后向前进行反向传播。l=loss(y_hat,y)l.backward()逐层通过前向网络,最后传递到损失张量l。以l为起点,自前向后传播,即可计算出计算图中各节点的梯度。backward的核心代码如下:defbackward(self,retain_graph:bool=False):fornodeinGraph.node_list[y_id::-1]:grad=node.gradforlastin[lforlinnode.lastifl.requires_grad]:add_grad=node.grad_fn(last,grad)last.grad+=add_gradGraph.node_list[y_id::-1]将以相反的顺序计算图形。节点是在前向传播过程中放入计算图中的每个张量。node.last是生成当前张量的直接父节点。调用node.grad_fn计算梯度并将其传回其父节点。grad_fn其实就是Tensor的推导公式,如:classpow(BinaryOperator):'''幂运算符,在Tensor类中重载参见-------add:Additionoperator'''defgrad_fn(self,node:Tensor,grad:np.ndarray)ifnodeisself.last[0]:return(self.data*self.last[1].data/node.data)*aftergradreturn代码其实就是幂函数推导公式。假设y=x^2,x的导数是2x。5.更新参数反向传播计算出梯度后,可以调用优化器更新模型参数。l.backward()optimizer.step()在本次训练中,我们使用梯度下降SGD算法对参数进行优化。更新过程如下:defstep(self):foriinrange(len(self.params)):grad=self.params[i].grad+self.weight_decay*self.params[i].dataself.v[i]*=self.momentumself.v[i]+=self.lr*gradself.params[i].data-=self.v[i]ifself.nesterov:self.params[i].data-=self.lr*gradself.params是整个网络的权重,在初始化SGD的时候传入。step函数的核心两行代码,self.v[i]+=self.lr*grad和self.params[i].data-=self.v[i],使用当前参数-学习率*梯度更新当前参数。这是机器学习的基础内容,大家应该都不陌生。一个模型训练的完整过程就大致走完了。可以设置打印语句,或者通过DEBUG跟踪每一行代码的执行过程,这样可以更好的了解模型的训练过程。
