优雅地理解神经网络反向传播 —— 将每一层视作对象

本文详细介绍了如何使用纯numpy库构建一个简单的神经网络,包括初始化、前向传播、反向传播和参数更新过程。通过实例展示了全连接层的实现,阐述了反向传播中每个层如何计算损失函数关于输入的梯度,并传递给前一层。最后,讨论了误差项在反向传播中的作用,以及如何利用误差项简化计算。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

用过 tensorflow 等的都知道,神经网络不同的 Layers 像拼图一样几乎可以随心所欲地拼接。

每一个层是一个对象,可以“独立地”实现前向传播、反向传播、参数更新,神经网络只需要按顺序调用它们就行了。

那么,如果试图使用纯 numpy 写这样的神经网络,应该如何实现呢?

实际上,把每一个层视作独立的对象后,反向传播的逻辑并未变复杂,反而更加清晰。


主要代码

想要实现一个名为Neuro的神经网络类,它有一个名为的 layers 列表。
列表中每一个层(layer)都是一个类对象,能被调用 forward, backward, update 三个函数进行前向传播、反向传播、参数更新。

class Neuro():
    def __init__(self,layers):
        self.layers = layers
    
    def fit(self,train_x,train_y,epochs,batch_size,learning_rate=0.1):
    	# initialization
        for layer in self.layers:
            layer.learning_rate = learning_rate

        for epoch in range(epochs):
            print('epoch = %d/%d '%(epoch+1,epochs))
            # random shuffle
            index = np.random.permutation(train_x.shape[0])
            
            for batch_num in range(train_x.shape[0],batch_size-1,-batch_size):
                _x = train_x[index[batch_num-batch_size : batch_num] , :]
                self.layers[-1].y = train_y[index[batch_num-batch_size : batch_num] , :]
				
				#forward
                for layer in self.layers:
                    _x = layer.forward(_x)
                
                #backward
                _derivative = None
                for layer in self.layers[::-1]:
                    _derivative = layer.backward(_derivative)
				
				#update
                for layer in self.layers:
                    layer.update()

    def predict(self,_x):
        for layer in self.layers:
            _x = layer.forward(_x)
        return _x

以下是全连接层的一个框架模板:

class Dense():
    # Dense Layer
    def __init__(self,height=1,width=1,act_type=1,learning_rate=0.1):
        # self.w , self.b = ... ,...

    def forward(self,_x):
        # return f(_x * self.w + self.b)

    def backward(self,_derivative):
        # return g(_derivative)
       
    def update(self):
        # self.w -= ... , self.b -= ...



前向传播

前向传播的每一层就像一个加工厂,将输进去的数据计算加工,把答案输出给下一层:

前向传播中,每一层根据输入计算输出,将其传给后一层。

注意!对于每一层,还需要用变量记住该层的输入,这是为了给反向传播做铺垫。

每一层可能是全连接,池化,卷积或者是reshape,但不管怎样都是将处理后的数据继续往下传递。

x0
x1=f(x)
x2=g(x1)
输入
第一层
第二层
输出

拿一个全连接层作为一个对象举例,由上一层传入 _ x \_x _x, 计算出下一层的数据(乘上权重 w w w ,加上转置 b b b ,并通过激活函数):

def forward(self,_x):
	# b should be broadcast to shape ( batch_size , width ) 
	# (automatically broadcast by numpy)
	self.x = _x
	self.z = np.dot(_x,self.w) + self.b
	return  self.activator.function()(self.z)

这里用了 self.x 来记录该层的输入



反向传播

先上思考得出的结论:看似复杂的反向传播,千言万语汇成一句话:

反向传播中,每一层计算损失函数关于该层输入的梯度,将其传给前一层。

这句话无论对于全连接层,还是输出层、池化层、卷积层、Flatten层……都直接套用雷打不动,不过为了方便,先拿全连接层举个例子。

dL/dx2
dL/dx1
输出层损失函数L
第二层
第一层

以上面的流程图为例,假设第二层是全连接层。即,假设在前向传播中,第一层给第二层输入了数据 x 1 x_1 x1 , 第二层对此加工给输出层输出了 x 2 x_2 x2

其中 , x 2 = f ( x 1 w + b ) x_2 = f(x_1w+b) x2=f(x1w+b),   f \ f  f为激活函数,   w , b \ w,b  w,b为权重和偏置, L L L为最终的损失函数。
x 1 , x 2 x_1,x_2 x1,x2 均为行向量 或 batch_size 个行向量构成的矩阵。

现在到了反向传播的时间,输出层把 ∂ L ∂ x 2 \frac{\partial L}{\partial x_2} x2L 传给了第二层。
(因为对于输出层,它的输入是来自于第二层的 x 2 x_2 x2

那么,第二层应该把 ∂ L ∂ x 1 \frac{\partial L}{\partial x_1} x1L 传给第一层。

至于如何计算,即已知 x 1 , ∂ L ∂ x 2 x_1,\frac{\partial L}{\partial x_2} x1,x2L 以及 x 2 = f ( x 1 w + b ) x_2 = f(x_1w+b) x2=f(x1w+b),求 ∂ L ∂ x 1 \frac{\partial L}{\partial x_1} x1L

很自然地,先把激活函数去掉,令 z = x 1 w + b z=x_1w+b z=x1w+b
∂ L ∂ z = ∂ L ∂ x 2 ∂ x 2 ∂ z = ∂ L ∂ x 2 ⊙ f ′ ( z ) \frac{\partial L}{\partial z}=\frac{\partial L}{\partial x_2}\frac{\partial x_2}{\partial z}=\frac{\partial L}{\partial x_2}\odot f'(z) zL=x2Lzx2=x2Lf(z)
所以,
∂ L ∂ x 1 = ∂ L ∂ z ∂ z ∂ x 1 = ∂ L ∂ z w T ( 1 ) \frac{\partial L}{\partial x_1}=\frac{\partial L}{\partial z}\frac{\partial z}{\partial x_1}=\frac{\partial L}{\partial z}w^T \quad (1) x1L=zLx1z=zLwT(1)


参数更新

经过前向传播,每一层都知道了该层的输入 x x x,以及该层对下一层的输出(也就是下一层的输入),记作 x ∗ x^* x
经过反向传播,每一层都得到了下一层传来的 ∂ L ∂ x ∗ \frac{\partial L}{\partial x^*} xL

那么最后一步,便是用 x x x ∂ L ∂ x ∗ \frac{\partial L}{\partial x^*} xL 计算损失函数关于该层参数的微分。

还是以全连接为例,已知了 x x x ∂ L ∂ x ∗ \frac{\partial L}{\partial x^*} xL ,以及输出与输入的关系
x ∗ = f ( x w + b ) x^*=f(xw+b) x=f(xw+b)
需要求出 ∂ L ∂ w , ∂ L ∂ b \frac{\partial L}{\partial w},\frac{\partial L}{\partial b} wL,bL

类似地,第一步当然是把激活函数去掉,假设 z = x w + b z=xw+b z=xw+b, 则可求得
∂ L ∂ z = ∂ L ∂ x ∗ ∂ x ∗ ∂ z = ∂ L ∂ x ∗ ⊙ f ′ ( z ) \frac{\partial L}{\partial z}=\frac{\partial L}{\partial x^*}\frac{\partial x^*}{\partial z}=\frac{\partial L}{\partial x^*}\odot f'(z) zL=xLzx=xLf(z)
第二步——也是最后一步,用 ∂ L ∂ z \frac{\partial L}{\partial z} zL计算 ∂ L ∂ w , ∂ L ∂ b \frac{\partial L}{\partial w},\frac{\partial L}{\partial b} wL,bL

由于 z = x w + b z=xw+b z=xw+b, 故(注意确保一下矩阵乘法维数对齐了)
∂ L ∂ b = ∂ L ∂ z ( 2 )     ∂ L ∂ w = ∂ z ∂ w ∂ L ∂ z = x T ∂ L ∂ z ( 3 ) \frac{\partial L}{\partial b}=\frac{\partial L}{\partial z}\quad (2) \\\ \\\ \frac{\partial L}{\partial w}=\frac{\partial z}{\partial w}\frac{\partial L}{\partial z}=x^T\frac{\partial L}{\partial z}\quad (3) bL=zL(2)  wL=wzzL=xTzL(3)

参数更新即让 w , b w,b w,b 分别减去 学习速率*梯度。大功告成。




现在也就能顺便回答为什么要有误差项的问题了,原因极其朴素—— ( 1 ) ( 2 ) ( 3 ) (1)(2)(3) (1)(2)(3)式都用到了 ∂ L ∂ z \frac{\partial L}{\partial z} zL,算一次用三次,省时省力,所以记误差项
δ = ∂ L ∂ z \delta = \frac{\partial L}{\partial z} δ=zL
并非奇思妙想抑或神来之笔,只不过是减少运算,仅此而已。


全连接层反向传播与参数更新的代码:

def backward(self,_derivative):
	# _derivative is an array with shape ( batch_size , width )
	# it is dLoss / dX(i+1)
	
	self.error = self.activator.derivative()(self.z) * _derivative
	return np.dot( self.error ,self.w.transpose() )
	
def update(self):
	self.w -= np.dot(self.x.transpose() , self.error) * self.learning_rate
	self.b -= np.sum(self.error,axis=0) * self.learning_rate
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值