用 Python 来手写一个神经网络|Python 主题月

1,581 阅读9分钟

本文正在参加「Python主题月」,详情查看活动链接

前言

动机

今天有很多好用成熟的深度学习框架、如 PyTorch 、Tensorflow 和 mxnet 等,在这些框架中已经把当下大部分流行算法和模型都已经封装好供我们调用。那么我们还有必要自己手写一个神经网络吗。其实个人认为还是有必要的,首先学习了深度学习的理论后,特表示反向传播这一块,如果不自己动手去写一个,就很难对反向传播有一个更深刻的认识。不仅限于此,在实际工作中,有时候因为任务不是那么负责,没有必要引入大型的框架或者你的模型需要在小型设备上进行训练和预测,这时候你就可以用底层语言实现简单神经网络。

一点点要求

  • 了解深度学习
  • 了解 python 以及其常用库,例如 numpy pandas 和 matplotlab 等

分享目标

一步一步地和大家手写一个用 python 实现网络,对于一些关键步骤给出解释和说明,帮助大家理解

import csv
import pandas as pd

这里引入 pandas 主要是为读取数据集和查看数据集,Pandas是一个强大的分析结构化数据的工具集。在数据挖掘和分析上也会经常用到这个库,如果还不了解觉得还是有必要好好学习一下。

006.jpeg

准备数据集

在开始写代码之前,让我们先谈谈任务,让任务来驱动我们去手写一个神经网络。那么只有对任务有一个更全面的了解,才能拿出对应任务或者说问题的解决方案。数据是预测一个人是否会得心脏病。将使用UCL数据库中的一个心脏病数据集。你可以在这里下载

数据特征

headers = ['age','sex','chest_pain','resting_blood_pressure','serum_clolestoral',
        'fasting_blood_sugar','resting_ecg_results','max_heart_rate_achieved','exercise_induced_angina',
        'oldpeak','slope_of_the_peak','number_of_major_vessels','thal','heart_disease']

设置数据集每个样本都具有哪些属性、最后一个列是标签,也就是表示该记录是否患有心脏病。关于这些指标如果大家感兴趣可以自行查找,这里就不做过多介绍了。

heart_df = pd.read_csv('./data/heart.dat',sep=' ',names=headers)
heart_df.head()
age sex chest_pain resting_blood_pressure serum_clolestoral fasting_blood_sugar resting_ecg_results max_heart_rate_achieved exercise_induced_angina oldpeak slope_of_the_peak number_of_major_vessels thal heart_disease
0 70.0 1.0 4.0 130.0 322.0 0.0 2.0 109.0 0.0 2.4 2.0 3.0 3.0 2
1 67.0 0.0 3.0 115.0 564.0 0.0 2.0 160.0 0.0 1.6 2.0 0.0 7.0 1
2 57.0 1.0 2.0 124.0 261.0 0.0 0.0 141.0 0.0 0.3 1.0 0.0 7.0 2
3 64.0 1.0 4.0 128.0 263.0 0.0 0.0 105.0 1.0 0.2 2.0 1.0 7.0 1
4 74.0 0.0 2.0 120.0 269.0 0.0 2.0 121.0 1.0 0.2 1.0 1.0 3.0 1

对于这些数据特征我们作为开发人员不用了解太多,也可以通过模型预测出不错结果。这就是深度学习好处,不要需要专业人来数据进行处理和特征工程。值的注意时最后列为数据标签,1 表示无,而 2 表示有心脏病。

heart_df.isna().sum()
age                        0
sex                        0
chest_pain                 0
resting_blood_pressure     0
serum_clolestoral          0
fasting_blood_sugar        0
resting_ecg_results        0
max_heart_rate_achieved    0
exercise_induced_angina    0
oldpeak                    0
slope_of_the_peak          0
number_of_major_vessels    0
thal                       0
heart_disease              0
dtype: int64

查看数据类型

#查看数据类型
heart_df.dtypes
age                        float64
sex                        float64
chest_pain                 float64
resting_blood_pressure     float64
serum_clolestoral          float64
fasting_blood_sugar        float64
resting_ecg_results        float64
max_heart_rate_achieved    float64
exercise_induced_angina    float64
oldpeak                    float64
slope_of_the_peak          float64
number_of_major_vessels    float64
thal                       float64
heart_disease                int64
dtype: object

数据中没有丢失数据以及打样本属性的数据类型都是 float64

将数据分离为训练集和测试集

在对数据有一个大概了解、如属性、数据是否完整以及每个属性对应数据类型后,我们就来开始作切分数据集为训练集和测试集。这里在切分数据集和对数据进行标准化的工作,借助了 sklearn 提供两个两个方法分别是 train_test_split 和 StandardScaler

import numpy as np
import warnings
warnings.filterwarnings("ignore")

import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
X = heart_df.drop(columns=['heart_disease'])

# 将 heart_disease 取之从 1 和 2 替换为 0 和 1 通作在作二分类问题更喜欢用 0 和 1 来表示两个类别

heart_df['heart_disease'] = heart_df['heart_disease'].replace(1,0)
heart_df['heart_disease'] = heart_df['heart_disease'].replace(2,1)

# print(heart_df['heart_disease'].values.shape)
# (270,) 转换为 (270,1) [[0],[0],...]
y_label =heart_df['heart_disease'].values.reshape(X.shape[0],1)

X_train, X_test, y_train,y_test = train_test_split(X,y_label,test_size=0.2,random_state=2)

sc = StandardScaler()
sc.fit(X_train)

X_train = sc.transform(X_train)
X_test = sc.transform(X_test)

print(f"Shape of train set: {X_train.shape}")
print(f"Shape of test set: {X_test.shape}")
print(f"Shape of train label set: {y_train.shape}")
print(f"Shape of test label  set: {y_test.shape}")
Shape of train set: (216, 13)
Shape of test set: (54, 13)
Shape of train label set: (216, 1)
Shape of test label  set: (54, 1)

定义层

我们早期神经网络结构比较简单,就是层堆叠,如果没有激活函数其实就是将我们数据(矩阵)作了一次线性变换。

neural_network.jpeg

神经网络中层主要是由输入、输出和参数(权重),如图结点之间边就是参数(权重)在全连接层就是,这是一个全连接层,所谓全连接也就是,输出向量每个元素(分量) 是所有输入所有分量经过线性变换得到 yj=iwijxi+bjy_j = \sum_{i} w_{ij} x_i + b_j

截屏2021-07-14下午2.42.43.png

class Layer:
    def __init__(self):
        self.input = None
        self.output = None
    def forward(self, input):
        raise NotImplementedError
    
    def backward(self,output_error, learning_rate):
        raise NotImplementedError

定义基础层

先定义层 Layer 基类也可以理解为接口,就是定义 Layer 大概什么样子,能够提供哪些功能,这里forwardbackward 分别对应前向传播和反向传播,在forward定义在传播中层中进行了哪些操作,而backward中计算在层输入变量和参数的梯度。

class FCLayer(Layer):
    def __init__(self,input_size,output_size):
        self.weights = np.random.rand(input_size,output_size) - 0.5
        self.bias = np.random.rand(1,output_size) - 0.5
    
    def forward(self,input_data):
        self.input = input_data
        self.output = np.dot(self.input,self.weights) + self.bias
        return self.output
    
    def backward(self,output_error,learning_rate):
#         print(output_error)
#         print(learning_rate)
        input_error = np.dot(output_error, self.weights.T)
        weights_error = np.dot(self.input.T, output_error)
        
        self.weights -= learning_rate * weights_error
        self.bias -= learning_rate * output_error
        return input_error

我们实现一个全连接层,前向传播 Y=XTW+BY = X^TW + B 关键是反向传播,对于变量 input 、权重 weights 和偏置 bias 反向求导是关键,这个放到后面一起解释。

class ActivationLayer(Layer):
    def __init__(self,activation, activation_prime):
        self.activation = activation
        self.activation_prime = activation_prime
    
    def forward(self,input_data):
        self.input = input_data
        self.output = self.activation(self.input)
        return self.output
    
    def backward(self,output_error, learning_rate):
        return self.activation_prime(self.input) * output_error

005.png

反向传播

其实神经网络前向传播比较好理解和实现,就是线性变换,可能用矩阵来表示运算大家对线性代数不算了解可能看上去有点陌生,不过只要简单翻一翻线性代数再回来看,问题不大,关键是反向传播就比较难理解,涉及到矩阵求导,今天例子还是比较简单。我接下来把推导过程都列出,只要有些底子也不难理解。感觉公式更有说服力。大家需要熟悉求导链式法则,然后当对某一个参数进行求导,顺藤摸瓜即可,需要考虑到从该参数出发所有到损失函数所有路径。

权重相当于损失函数求导

neural_network.jpeg

截屏2021-07-14下午2.57.34.png

yj=bj+ixiwijy_j = b_j + \sum_{i}x_i w_{ij}

这里我们以 wijw_{ij} 相对于损失函数求偏导为例,wijw_{ij} 路径从上图来看还是比较简单途径 yjy_j 与输出或者损失连接。

EW=[Ew11Ew1jEwi1Ewij]\frac{\partial E}{\partial W} = \begin{bmatrix} \frac{\partial E}{\partial w_{11}} & \cdots & \frac{\partial E}{\partial w_{1j}}\\ \vdots & \cdots & \vdots \\ \frac{\partial E}{\partial w_{i1}} & \cdots & \frac{\partial E}{\partial w_{ij}} \end{bmatrix}

参数对于损失(输出)值求导,损失值是一个标量或者是一个向量,每一个参数矩阵每一个元素对 E 进行求导。

yj=iwijxiyj=x1w1j+x2w2j++xiwij\begin{aligned} y_j = \sum_{i} w_{ij} x_i\\ y_j = x_1 w_{1j} + x_2 w_{2j} + \cdots + x_i w_{ij}\\ \end{aligned}

我们结合图来看如何计算个直观,对于输出 Y 向量第 j 维分量来说,是输入 X 样本所有元素(特征)乘以一个对应权重,yjy_j 对于 wijw_{ij} 就是其系数 xix_i

yjwij=xi\frac{\partial y_j}{\partial w_{ij}} = x_i

Ey=jEyj\frac{\partial E}{\partial y} = \sum_{j} \frac{\partial E}{\partial y_j}

然后利用乘法链式法则得到下面公式

Ewij=Eyjyjwij=Eyjxi\frac{\partial E}{\partial w_{ij}} = \frac{\partial E}{\partial y_j} \frac{\partial y_j}{\partial w_{ij}} = \frac{\partial E}{\partial y_j} x_i

然后将这个方法应用到 W 矩阵上每一个元素进行求导表示后,再用矩阵表示一下。

EW=[Ey1x1Eyjx1Ey1xiEyjxi]=[x1xi]Ey1Eyj=XTEY\frac{\partial E}{\partial W} = \begin{bmatrix} \frac{\partial E}{\partial y_{1}}x_1 & \cdots & \frac{\partial E}{\partial y_{j}} x_1\\ \vdots & \cdots & \vdots \\ \frac{\partial E}{\partial y_{1}} x_i& \cdots & \frac{\partial E}{\partial y_{j}} x_i \end{bmatrix} = \begin{bmatrix} x_1\\ \vdots\\ x_i \end{bmatrix} \begin{aligned} \frac{\partial E}{\partial y_{1}} & \cdots & \frac{\partial E}{\partial y_{j}} \end{aligned} = X^T \frac{\partial E}{\partial Y}

偏置相当于损失函数求导

接下来就是求偏置,因为 bib_i 仅与 yiy_i 有关,所以计算梯度相对要比较简单。这里不做过多解释,如果大家感兴趣请给我留言。

EB=[Eb1Eb2Ebj]\frac{\partial E}{\partial B} = \begin{bmatrix} \frac{\partial E}{\partial b_{1}} & \frac{\partial E}{\partial b_{2}} & \cdots & \frac{\partial E}{\partial b_{j}} \end{bmatrix}

Ebj=Eyj\frac{\partial E}{\partial b_j} = \frac{\partial E}{\partial y_j}

EB=[Ey1Ey2Eyj]=EY\frac{\partial E}{\partial B} = \begin{bmatrix} \frac{\partial E}{\partial y_1} & \frac{\partial E}{\partial y_2} & \cdots & \frac{\partial E}{\partial y_j} \end{bmatrix} = \frac{\partial E}{\partial Y}

变量 X 对于输出求导

神经网络反向传播不是为对参数求梯度来更新参数,为什么还要对变量进行求导呢?因为关于损失值梯度还需要一层一层向下传递,所以有必要对变量进行求梯度。

EX=[Ex1Ex2Exi]\frac{\partial E}{\partial X} = \begin{bmatrix} \frac{\partial E}{\partial x_{1}} & \frac{\partial E}{\partial x_{2}} & \cdots & \frac{\partial E}{\partial x_{i}} \end{bmatrix}

对 X 变量输入时,也需要对样本每一个元素分别进行求偏导,然后组合在一起就是 X 对于 E 偏导。

neural_network.jpeg

还需要看一下这张图,我们还是对每一个 X 每一个元素看到其到输出会经过多少路径,然后然每条路径进行求导后再汇总。

Exi=Ey1y1xi++Eyjyjxi\frac{\partial E}{\partial x_i} = \frac{\partial E}{\partial y_1} \frac{\partial y_1}{\partial x_{i}} + \cdots + \frac{\partial E}{\partial y_j} \frac{\partial y_j}{\partial x_{i}}
Exi=Ey1wi1++Eyjwij\frac{\partial E}{\partial x_i} = \frac{\partial E}{\partial y_1} w_{i1}+ \cdots + \frac{\partial E}{\partial y_j} w_{ij}

如果大家对这个推导过程还不了解,可以自己按着上面图查一查。

EX=[(Ey1w11++Eyjw1j)Ey1wi1++Eyjwij]\frac{\partial E}{\partial X} = \begin{bmatrix} ( \frac{\partial E}{\partial y_1} w_{11}+ \cdots + \frac{\partial E}{\partial y_j} w_{1j}) & \cdots & \frac{\partial E}{\partial y_1} w_{i1}+ \cdots + \frac{\partial E}{\partial y_j} w_{ij} \end{bmatrix}

整理后我们用矩阵形式来表示实现上面矩阵求导。

EX=EYWT\frac{\partial E}{\partial X} = \frac{\partial E}{\partial Y} W^T

激活函数方向求导

Y=[f(x1)f(xi)]Y = \begin{bmatrix} f(x_1) & \cdots & f(x_i) \end{bmatrix}
EX=[Ex1Ex2Exi]=[Ex1y1x1Ey2y2x2Eyiyixi][Ex1f(x1)Ey2f(x2)Eyif(xi)]=[Ex1Ex2Exi][f(x1)f(x2)f(xi)]=EYf(X)\begin{aligned} \frac{\partial E}{\partial X} = \begin{bmatrix} \frac{\partial E}{\partial x_{1}} & \frac{\partial E}{\partial x_{2}} & \cdots & \frac{\partial E}{\partial x_{i}} \end{bmatrix}\\ = \begin{bmatrix} \frac{\partial E}{\partial x_{1}} \frac{\partial y_1}{\partial x_{1}} & \frac{\partial E}{\partial y_{2}}\frac{\partial y_2}{\partial x_{2}} & \cdots & \frac{\partial E}{\partial y_{i}}\frac{\partial y_i}{\partial x_{i}} \end{bmatrix}\\ \begin{bmatrix} \frac{\partial E}{\partial x_{1}} f^{\prime}(x_1) & \frac{\partial E}{\partial y_{2}}f^{\prime}(x_2) & \cdots & \frac{\partial E}{\partial y_{i}}f^{\prime}(x_i) \end{bmatrix}\\ = \begin{bmatrix} \frac{\partial E}{\partial x_{1}} & \frac{\partial E}{\partial x_{2}} & \cdots & \frac{\partial E}{\partial x_{i}} \end{bmatrix} \begin{bmatrix} f^{\prime}(x_1) & f^{\prime}(x_2) & \cdots & f^{\prime}(x_i) \end{bmatrix}\\ =\frac{\partial E}{\partial Y}f^{\prime}(X) \end{aligned}

损失函数

E=1nin(yiyi)2E = \frac{1}{n} \sum_{i}^n (y^{*}_i - y_i)^2

EY=2n(YY)\frac{\partial E}{\partial Y} = \frac{2}{n}(Y - Y^*)

def tanh(x):
    return np.tanh(x)

def tanh_prime(x):
    return 1-np.tanh(x)**2
def mse(y_true, y_pred):
    return np.mean(np.power(y_true - y_pred,2))

def mse_prime(y_true,y_pred):
    return 2*(y_pred - y_true)/y_true.size

007.jpeg

定义网络结构

网络结构中,这里是一个简单逐层叠加网络,所以在构造函数中我们维护列表,然后通过 add 方法往神经网络添加全连接层和激活层,loss 和 loss_prime 分别接受两个方法,一个用于计算损失值一个用于在反向传播是计算梯度。fit 函数用于训练网络,根据模型输出结果和真实值之间差距更新网络中每层中参数,而 predict 就是利用模型对输入数据进行预测。

class Network:
    
    def __init__(self):
        self.layers = []
        self.loss = None
        self.loss_prime = None
        
    def add(self, layer):
        self.layers.append(layer)
        
    def use(self, loss, loss_prime):
        self.loss = loss
        self.loss_prime = loss_prime
    
    def predict(self, input_data):
        samples = len(input_data)
        result = []
        
        for i in range(samples):
            output = input_data[i]
            for layer in self.layers:
                output = layer.forward(output)
            result.append(output)
        return result
    
    def fit(self, x_train, y_train, epochs, learning_rate):
        samples = len(x_train)
        
        for i in range(epochs):
            err = 0
            for j in range(samples):
                output = x_train[j]
                for layer in self.layers:
                    output = layer.forward(output)
                err += self.loss(y_train[j],output)
                
                error = self.loss_prime(y_train[j],output)
#                 print(f"error: {error}")
                for layer in reversed(self.layers):
#                     print(f"error: {error}")
                    error = layer.backward(error, learning_rate)
            err /= samples
            print(f"epoch {(i+1)/epochs} error ={err}")

这里用于一个简单数据集 XOR ,然后让网络先在这个数据集运行看看效果。

003.png

x_train = np.array([[[0,0]],[[0,1]],[[1,0]],[[1,1]]])
y_train = np.array([[[0]],[[1]],[[1]],[[0]]])

基础设施都准备好了,接下里工作就是用这些模块来搭建一个网络,现在就为解决 XOR 这个任务来搭建一个神经网络。

001.jpeg

net = Network()
net.add(FCLayer(2,3))
net.add(ActivationLayer(tanh,tanh_prime))
net.add(FCLayer(3,1))
net.add(ActivationLayer(tanh,tanh_prime))

net.use(mse,mse_prime)
net.fit(x_train,y_train,epochs=2000, learning_rate=0.01)

out = net.predict(x_train)
print(out)
epoch 0.0005 error =0.7136337374260526
epoch 0.001 error =0.5905739289052101
epoch 0.0015 error =0.49847803406876834
epoch 0.002 error =0.4315229169435869
epoch 0.0025 error =0.3833645522357618
epoch 0.003 error =0.34869455999659626
epoch 0.0035 error =0.3235475456763668
epoch 0.004 error =0.3051103124983356
epoch 0.0045 error =0.29142808210519544
epoch 0.005 error =0.28114855069402345

[array([[0.01603471]]), array([[0.884377]]), array([[0.89208809]]), array([[0.02946468]])]

从预测结构来看,神经网络通过 2000 次迭代的确学到东西,给出不错答案

[array([[0.01603471]]), array([[0.884377]]), array([[0.89208809]]), array([[0.02946468]])]

009.jpeg

试一试

给大家留一个作业,呵呵,不敢当。自己动手写一个网络,然后跑跑这个数据集,我跑是否患有心脏病效果不是很好,大家可调一调,跑一跑。

X_train = X_train.reshape(X_train.shape[0],1,X_train.shape[1])
net2 = Network()
net2.add(FCLayer(13,8))
net2.add(ActivationLayer(tanh,tanh_prime))
net2.add(FCLayer(8,5))
net2.add(ActivationLayer(tanh,tanh_prime))
net2.add(FCLayer(5,2))
net2.add(ActivationLayer(tanh,tanh_prime))

net2.use(mse,mse_prime)
net2.fit(X_train,y_label,epochs=35, learning_rate=0.1)
X_test = X_test.reshape(X_test.shape[0],1,X_test.shape[1])
output = net2.predict(X_test[0:3])
output
[array([[0.82602593, 0.82602593]]),
 array([[0.17209814, 0.17209814]]),
 array([[0.95838074, 0.95838074]])]
y_test[:3]

这个看输出好像模型没有学到,随后在此基础进一步调整,这里还有没有 tensor ,随后可以参考

array([[1],
       [0],
       [0]])

如果大家对上面内容还有疑问,欢迎留言。由于写比较仓促,如果有遗漏或者出路地方还希望大家多多指正批评。