《机器学习》之一文读懂神经网络的原理及实现

神经网络原理浅析及代码实现

Posted by 予以初始 on July 24, 2020

1 介绍

本文内容主要包含神经网络(NN)的原理以及代码实现。我看了很多神经网络的实现方法,但全部都是结构固定,扩展性差。本文将实现一种可以热拔插的代码来实现神经网络,无需修改代码,只需修改参数即可搭建不同结构的神经网络。

2 原理及代码

看了很多文章,博主觉得讲原理时配上代码,食用更佳。

2.1 正向传播

正向传播很简单,不在详细介绍,正向传播的公式如下: 在这里插入图片描述 上式是三层结构的一个前向传播公式,相信大家都能看懂,$\sigma$ 为激活函数,在本文中表示sigmoid。本文所有的公式中的变量都使用矩阵形式,避免太多的累加符号,不易理解。

为了求导时容易理解,下面再定义每层神经元未激活时的输出为 z , 激活后为 a 。即:

在这里插入图片描述在这里插入图片描述

在正向传播之前,需要先随机初始化权重W及偏置b,下面代码中的layer_list是要输入的各层神经元个数,比如[32, 16, 8, 1], 第一个数32为输入X的特征个数。那么各层权重即可根据各层神经元的个数来定义对应的shape。代码如下:

    def __init__(self, layer_list=[], lr=0.1, epochs=100):
        self.lr = lr					#学习率
        self.layer_list = layer_list	#每层神经元个数
        self.epochs = epochs			#迭代次数
        
    def weight_bias_init(self):
        self.W = {}     #权重字典,key是层号,value是对应权重矩阵
        self.b = {}     #偏置字典,key是层号,value是对应偏置矩阵

        self.layer_num = len(self.layer_list)-1    #网络层数(权重矩阵的个数,输入层无权重)
        #为每层layer初始化W与b矩阵
        for idx in range(self.layer_num):
        	# 每层 W 的shape为(前一层神经元个数,后一层神经元个数)
            self.W[idx] = np.random.randn(self.layer_list[idx], \
            					self.layer_list[idx+1]) * 0.01 #正态分布
            self.b[idx] = np.random.randn(self.layer_list[idx+1])

有了矩阵及偏置后,对输入X进行累乘即可得到输出output,代码如下:

    def forward(self, X, y):
        self.X = X  #将输入X保存为类的属性,可供其他函数使用
        self.y = np.array(y).reshape(-1, 1) #更改y的shape,防止运算出错
        
        #记录各层的z与a,反向传播时会用到
        self.z = {}  #字典,记录每层激活前的输出(z = W*X + b)
        self.a = {}  #字典,记录每层激活后的输出(a = sigmoid(z))
        
        #循环向前累乘
        input = self.X
        for idx in range(self.layer_num):
            self.z[idx] = np.dot(input, self.W[idx]) + self.b[idx]	#z = W*X + b
            self.a[idx] = self.sigmoid(self.z[idx])					#a = sigmoid(z)
            input = self.a[idx]    #更新输入

        self.output = self.a[self.layer_num-1]      	#记录最后一层输出
        self.loss = np.mean((self.output - self.y)**2)  #计算均方误差,因为是矩阵形式,所以需要取mean

此时,就实现了对输入X的正向传播,并且记录了各层的输出z与a,很简单吧!

2.2 误差反传

对于二分类的对数损失函数: 在这里插入图片描述 需要求L对各个网络层的权重W及偏置b的导数,即: 在这里插入图片描述在这里插入图片描述 马上要链式求导了,所以先梳理一下要求导的目标位置。 1、全部的权重W及b都包含在 $y_{pre}$ 中; 2、每层的权重比如$W_{2}$与$b_{2}$都位于该层的输出$z_{2}$与$a_{2}$中,所以求导需先对z或者a求导,再对W及b求导; 3、前层的输出比如$z_{1}$与$a_{1}$都位于后层的输出$z_{2}$与$a_{2}$中,所以对前层权重求导时需先对后层的输出求导; 4、要对每层的W和b求导,只需求得每层输出z的导数dz即可,因为z=W*X+b, 所以dW=Xdz, db=dz,有了每层的dz,dW与db就很好求了。

所以我们的链式求导,只针对每层的未激活的输出z进行求导得到dz。然后再根据dz求dW与db。

先对最后一层的输出$z_{3}$进行求导,最后一层求导最麻烦,前层都可以迭代得到: 在这里插入图片描述在这里插入图片描述 求导很容易得到:$dz_{3}$=($y_{pre}-y_{true}$)* $\sigma^{‘}$($z_{3}$), 参照逻辑回归求导过程,不熟悉可以先记住结果,继续往下看。有了$dz_{3}$,那么$dW_{3}$与$db_{3}$就很好求了。 有了最后一层输出的导数$dz_{3}$,那么前面所有层的输出就可以迭代计算得到了。 在这里插入图片描述在这里插入图片描述在这里插入图片描述 同理,得到迭代格式: 在这里插入图片描述在这里插入图片描述

即前一层输出z的导数$dz_{2}$等于后一层的权重$W_{3}$,乘上sigmoid函数的导数,再乘后一层z的导数$dz_{3}$即可。如此迭代可得到所有层的dz,最后再根据dz计算每层的dW与db即可, 如下: 在这里插入图片描述在这里插入图片描述在这里插入图片描述 Tips: 误差反向传播是不是也很简单,不要先想着对W与b进行求导,它们嵌套的太深,求导复杂。换一个角度,只对每层未激活时的输出z进行求导,再根据dz对W与b进行求导,这个问题就变得清晰了。

    # sigmoid的一阶导数
    def Dsigmoid(self, x):
        return self.sigmoid(x) * (1 - self.sigmoid(x))
	# 反向传播
    def backward(self):
    	#跟权重保存方式一样,使用字典存储,key为对应的层号
        self.dz = {}    #对每层z的求导
        self.dW = {}    #对每层W的求导
        self.db = {}    #对每层b的求导

        idx = self.layer_num - 1	#从后往前求导
        while(idx>=0):
        	#最后一层的求导比较特殊,套最后一层求导的公式dz3
            if(idx == self.layer_num-1): 
                self.dz[idx] = (self.output-self.y) * self.Dsigmoid(self.z[idx])  #元素乘
            #前层都可根据最后一层的dz迭代得到,套迭代公式dzi
            else:
                self.dz[idx] = np.dot(self.dz[idx+1], self.W[idx+1].T) \
                	* self.Dsigmoid(self.z[idx]) 
                	
            #####求完dz,再求dW与db########   
            if(idx == 0):   #idx为0时,即到达第一层时,前层输入a[idx-1]是X
                self.dW[idx] = np.dot(self.X.T, self.dz[idx]) / len(self.X) #梯度需除上总样本数
            else:			#idx不为0时迭代计算即可
                self.dW[idx] = np.dot(self.a[idx-1].T, self.dz[idx]) / len(self.X)
            self.db[idx] = np.sum(self.dz[idx], axis=0) / len(self.X) #db=dz, 但是需要所有维度取平均
            idx -= 1	#跳前一层
            
        # 求完所有层的梯度后,更新即可
        for idx in range(self.layer_num):
            self.W[idx] -= self.lr * self.dW[idx]
            self.b[idx] -= self.lr * self.db[idx]

到此,前向传播与反向传播的函数都已经实现了,最后用一个train函数进行封装即可实现完整的神经网络代码。

3 完整代码

此代码是我在学习了好朋友的文章之后,扩展的更灵活的版本,觉得有难度的话可以先看看他的文章。 下面是我的完整版代码:

import numpy as np

class NN(object):
    def __init__(self, layer_list=[], lr=0.1, epochs=100):
        self.lr = lr					#学习率
        self.layer_list = layer_list	#每层神经元个数
        self.epochs = epochs			#迭代次数
        
	#权重与偏执初始化
    def weight_bias_init(self):
        self.W = {}     #权重字典,key是层号,value是权重矩阵
        self.b = {}     #偏置字典,key是层号,value是怕偏置矩阵

        self.layer_num = len(self.layer_list)-1    #网络层数
        #为每层layer初始化W与b矩阵
        for idx in range(self.layer_num):
            self.W[idx] = np.random.randn(self.layer_list[idx], \
            					self.layer_list[idx+1]) * 0.01 #正态分布
            self.b[idx] = np.random.randn(self.layer_list[idx+1])

    # sigmoid函数
    def sigmoid(self, x):
        return 1.0 / (1 + np.exp(-x))
    # sigmoid的一阶导数
    def Dsigmoid(self, x):
        return self.sigmoid(x) * (1 - self.sigmoid(x))

    # 前向传播
    def forward(self, X, y):
        self.X, self.y = X, np.array(y).reshape(-1, 1)
        self.z = {}  #记录每层激活前的输出(z = W*X + b)
        self.a = {}  #记录每层激活后的输出(a = sigmoid(z))
        #循环向前累乘
        input = self.X
        for idx in range(self.layer_num):
            self.z[idx] = np.dot(input, self.W[idx]) + self.b[idx]
            self.a[idx] = self.sigmoid(self.z[idx])
            input = self.a[idx]    #更新输入
        self.output = self.a[self.layer_num-1]      #最后一层输出
        self.loss = np.mean((self.output - self.y)**2)  #均方误差

    # 反向传播
    def backward(self):
    	#跟权重保存方式一样,使用字典存储,key为对应的层号
        self.dz = {}    #对每层z的求导
        self.dW = {}    #对每层W的求导
        self.db = {}    #对每层b的求导
        idx = self.layer_num - 1	#从后往前求导
        while(idx>=0):
        	#最后一层的求导比较特殊,套最后一层求导的公式dz3
            if(idx == self.layer_num-1): 
                self.dz[idx] = (self.output-self.y) * self.Dsigmoid(self.z[idx])  #元素乘
            #前层都可根据最后一层的dz迭代得到,套迭代公式dzi
            else:
                self.dz[idx] = np.dot(self.dz[idx+1], self.W[idx+1].T) \
                	* self.Dsigmoid(self.z[idx]) 
            #####求完dz,再求dW与db########   
            if(idx == 0):   #idx为0时,即到达第一层时,前层输入a[idx-1]是X
                self.dW[idx] = np.dot(self.X.T, self.dz[idx]) / len(self.X) #梯度需除上总样本数
            else:			#idx不为0时迭代计算即可
                self.dW[idx] = np.dot(self.a[idx-1].T, self.dz[idx]) / len(self.X)
            self.db[idx] = np.sum(self.dz[idx], axis=0) / len(self.X) #db=dz, 但是需要所有维度取平均
            idx -= 1	#跳前一层
        # 求完所有层的梯度后,更新即可
        for idx in range(self.layer_num):
            self.W[idx] -= self.lr * self.dW[idx]
            self.b[idx] -= self.lr * self.db[idx]
	
	#迭代训练
    def train(self, X, y):
        self.weight_bias_init()
        for i in range(self.epochs):
            self.forward(X, y)
            self.backward()
            #每10轮打印一次loss
            if(i%10==0): print("Epoch {}: loss={}".format(i//10+1, self.loss))
	
	#预测概率输出
    def predict(self, X_test):
        input = X_test
        for idx in range(self.layer_num):
            z = np.dot(input, self.W[idx]) + self.b[idx]
            a = self.sigmoid(z)
            input = a
        return a

使用测试:

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

X, y = load_iris(return_X_y=True)
X y = X[:100], y[:100]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.4)

layer_list = [4, 1] 
model = NN(layer_list, lr=0.1, epochs=100)
model.train(X_train, y_train)
pre = model.predict(X_test)
pre = [1 if x>=0.5 else 0 for x in pre]
print(accuracy_score(pre, y_test))

此代码写完之后,调试了很久。几束青丝又不经意间飘落,程序员太苦了~ 码字不易,觉得有用就点个赞吧~万分感谢