Create a neural network from scratch——如何手搓简单神经网络


前言

寒假开始了十几天我啥都没干,那就把去年国庆期间搞的手搓神经网络写成博客吧。

实际人工智能理论在195x年就诞生了,这意味着人工智能之所以现在才飞黄腾达有着部分硬件算力不足的原因,也同时意味着最简单功能的人工智能,或者说是神经网络背后的数学逻辑或许没有那么复杂。

众所周知,现在简单有名好用的神经网络框架已经有不少了。pytorch,tensorflow等等的大公司花了不少钱开发的框架真是功能相当强大,但是完成一个较为简单的梯度下降神经网络根本用不上多少这些框架,那么能不能只用numpy和python还有一些线性代数来手搓一个神经网络区分经典的MNIST数据集呢(0-9手写数字)?


0.准备

MNIST中每一张图片都是28*28像素的图片,而由于它们都是灰度图,所以每个像素对应的是从0-255的单值(0是全黑,255是全白),所以对于MNIST中的每一张图片都可以用一个784维向量来描述,设m是我们的训练集中图片的个数,我们就可以用一个784*m的矩阵来描述整个训练集X了,而一个深度学习过程矩阵总是以列为一个样本,所以我们转置它。

X=\begin{bmatrix}
    n_1\\
    n_2\\
    \vdots\\
    n_m\\
     \end{bmatrix}^{\rm T},\space n_i=(x_{1},x_{2},\cdots,x_{784})

任务不算困难,神经网络中一个隐藏层兴许就够了,也就是比较经典的三层神经网络,又一个输入层一个隐藏层和一个输出层形成:

1.前向传播

那么经典的三层神经网络学习过程的第一步就是前向传播(forward propagation),也就是在这个网络中的参数都是初始值时识别出一个结果来(无论对错),就像婴儿学习说话,总是在大脑里先对一个声音有一个初始的认知(无论理解对错)。

每一层神经网络都会对上一层传下来的东西用权重(weight)和偏置(bias)线性调整,再经过一个激活函数再传递给下一层,这个激活函数一定是非线性的,不然每个下一层只是上一层的线性组合,就会变成线性回归模型,那么最终得到的网络会对训练集有着百分之百的拟合度但是却不认识训练集之外的东西,这是我们不希望看到的,在隐藏层中的激活函数多种多样也各有作用,比如tanh, sigmoid, ReLU, softmax…….

Z^{[i+1]}=W^{[i+1]}A^{[i]}+b^{[i+1]},\\A^{[i+1]}=\sigma(Z^{[i+1]})

Z就是i+1层还没有被激活的矩阵,A则是被激活的,sigma代表的就是激活函数。在这个任务当中我们选择ReLU作为隐藏层激活函数,因为它跑的快啊!

A^{[0]}=X,\\Z^{[1]}=W^{[1]}A^{[0]}+b^{[1]},\\A^{[1]}={\rm ReLU}(Z^{[1]})

而对于输出层,要注意我们最后希望让这个网络得到一个十维的1*10矩阵,每一个位置对应识别0-9的概率,所以我们希望找一个函数值在0-1之间并且求和可以归一的激活函数,那便是神奇的softmax函数了,下面是它的表达式:

{\rm softmax}(Z)=A,A_i=\frac{e^{Z_i}}{\sum\limits^k_{j=1}e^{Z_j}}

Z是一个k*1的列向量,Z_i,A_i表示Z,A的第i行的值。

那么A^[1]接下来经过输出层就要变成输出了:

Z^{[2]}=W^{[2]}A^{[1]}+b^{[2]},\\\hat y= A^{[2]}={\rm softmax}(Z^{[1]})

2.反向传播

就像上一小节说的,正向传播就是类似婴儿第一次认知(无论对错),那么我们必须让它知道它的认知是不是对的,因此我们需要一个反向传播(backward propagation),而且我们不仅仅是要让它知道它对不对,还要让它知道它错哪儿了。

反向传播的过程不难,基本就是一些”fancy math”,我们把输出和真正的结果相减得到一个误差,再往回带,进行一个求偏微分的过程得出权重W和偏置b各自有多少贡献出了误差,就能让这个网络知道自己错哪儿了。

省去麻烦的”fancy math”推导过程,我就直接列出下面的结论了:

error^{[2]}=A^{[2]}-Y,\\
dW^{[2]}=\frac{1}{m}error^{[2]}{A^{[1]}}^{\rm T},\\
db^{[2]}=\frac{1}{m}\sum \limits^{column} error^{[2]},\\
error^{[1]}={W^{[2]}}^{\rm T}error^{[2]}{\rm ReLU}'(Z^{[1]}),\\
dW^{[1]}=\frac{1}{m}error^{[1]}{A^{[0]}}^{\rm T},\\
db^{[1]}=\frac{1}{m}\sum \limits^{column} error^{[1]},\\
(Y\space looks \space like \begin {bmatrix}0\\0\\1\\\vdots\\0\\\end{bmatrix})

上面例子中的Y后面的例子代表样本的label是2,dW,db分别就是在代表W和b中贡献误差的部分。可以注意的一点就是dW,db都是除去m(样本数)求均值之后的结果。抛去复杂的数学而言,这一部分还是好理解的。

3.更新参数

上一小节介绍了如何让这个网络知道自己错在哪里,学习不仅仅是知错,还要知错能改才能进步,因此我们还需要最后一步更新参数,这步就非常简单了,不过这之中涉及一个alpha参数,这是一个学习率,需要手动设置。

W^{[1]}=W^{[1]}-\alpha dW^{[1]},\\
b^{[1]}=b^{[1]}-\alpha db^{[1]},\\
W^{[2]}=W^{[2]}-\alpha dW^{[2]},\\
b^{[2]}=b^{[2]}-\alpha db^{[2]}\\

不积跬步,无以至千里,这三步做一遍是不够的,我们下面要做的是再次1.前向传播,然后重复这个过程,然后我们的神经网络就会学习知识了。


coding:

首先我们先要导入数据并且做好准备,获取一些需要的数。

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt

data = pd.read_csv('./dataset/train.csv')

data = np.array(data)
m, n = data.shape
np.random.shuffle(data)

那么代码上先用panda导入train.csv(这个文件会放在文章末尾),再让它变成numpy矩阵,为了训练集的严谨性,shuffle一下!注意train.csv的第一列是这一行(一个手写数字)的正确答案。(matplotlib是为了最后让结果更加直观的绘图库,最后会用到)

在一个学习过程中可能总是会有过度拟合的风险,也就是出现网络只认识训练集而不懂得如何类推认知的情况,所以我们不能用整个train.csv作为训练集,而应该把它分成训练集和开发集,来避免这样的情况。训练集用于训练,而开发集用于检测是不是过度拟合了(虽然我的代码不包含这一部分)。

data_dev = data[0:1000].T
label_dev = data_dev[0]
X_dev = data_dev[1:n]
X_dev = X_dev / 255.

data_train = data[1000:m].T
label_train = data_train[0]
X_train = data_train[1:n]
X_train = X_train / 255.
_,m_train = X_train.shape

开发集矩阵取数据集的前1000个样本并转置(因为我们希望他是一个列向量为样本的矩阵)label_dev就是第一行,指向每个样本真正的代表数字(label),训练集取1000之后的并做类似的处理,m_train是最终我们真正训练的样本数量。而我们把每个像素都除255,以便于处理。

接下来我们应该开始进行前向传播了,第一步是我们需要一些W和b的初始值:

def init_params():
    W1 = np.random.rand(10, 784) - 0.5
    b1 = np.random.rand(10, 1) - 0.5
    W2 = np.random.rand(10, 10) - 0.5
    b2 = np.random.rand(10, 1) - 0.5
    return W1, b1, W2, b2

np.random.rand函数会给矩阵的每一个元素赋值一个0-1的值,但是对于一个W和b我们希望他们是区间上对称的,有正有负,所以我们要减去0.5让他们在区间上是-0.5到0.5。

其次前向传播还需要我们的激活函数:

def ReLU(Z):
    return np.maximum(Z, 0)

def softmax(Z):
    A = np.exp(Z) / sum(np.exp(Z))
    return A

这部分不难理解。

下面来写前向传播的主体:

def forward_prop(W1, b1, W2, b2, X):
    Z1 = W1.dot(X) + b1
    A1 = ReLU(Z1)
    Z2 = W2.dot(A1) + b2
    A2 = softmax(Z2)
    return Z1, A1, Z2, A2

同样也不难理解,都是把数学代码化的过程。

接下来应该是backward propagation了,但是在这之前,我们应该注意一件事情,对于训练集给的label,都是一些0-9的数字形成的行向量,但是我们在反向传播过程中实际希望看到的Y是这样的(上一小章第二节所说的):

Y\space looks \space like \begin {bmatrix}0\\0\\1\\\vdots\\0\\\end{bmatrix}

所以我们要设计一个函数,把原来的label向量变成一个每一列代表label的零一矩阵:

def zero_one(Y):
    zero_one_Y = np.zeros((Y.size, Y.max() + 1))
    zero_one_Y[np.arange(Y.size), Y] = 1
    zero_one_Y = zero_one_Y.T
    return zero_one_Y

这个方法我个人认为还是比较巧妙有趣的,而反向传播的过程中还涉及了ReLU'(Z):

def ReLU_deriv(Z):
    return Z > 0

那么下面正式来写反向传播:

def backward_prop(Z1, A1, Z2, A2, W1, W2, X, Y):
    zero_one_Y = zero_one(Y)
    error2 = A2 - zero_one_Y
    dW2 = 1 / m * error2.dot(A1.T)
    db2 = 1 / m * np.sum(error2)
    error1 = W2.T.dot(error2) * ReLU_deriv(Z1)
    dW1 = 1 / m * error1.dot(X.T)
    db1 = 1 / m * np.sum(error1)
    return dW1, db1, dW2, db2

到一个循环的最后一步了,更新参数:

def update_params(W1, b1, W2, b2, dW1, db1, dW2, db2, alpha):
    W1 = W1 - alpha * dW1
    b1 = b1 - alpha * db1
    W2 = W2 - alpha * dW2
    b2 = b2 - alpha * db2
    return W1, b1, W2, b2

接下来为了让代码更直观,我们还需要几步,我们要先把上面的步骤拼接成完整的梯度下降,并且再完成一下准确率的判定:

def get_predictions(A2):
    return np.argmax(A2, 0)

在A2中找到概率最大的label并return得到网络的猜测。

def get_accuracy(predictions, Y):
    print(predictions, Y)
    return np.sum(predictions == Y) / Y.size

通过A2得到的猜测和Y对比得到准确度。

下面可以写出完整的梯度下降了:

def gradient_descent(X, Y, alpha, iterations):
    W1, b1, W2, b2 = init_params()
    for i in range(iterations):
        Z1, A1, Z2, A2 = forward_prop(W1, b1, W2, b2, X)
        dW1, db1, dW2, db2 = backward_prop(Z1, A1, Z2, A2, W1, W2, X, Y)
        W1, b1, W2, b2 = update_params(W1, b1, W2, b2, dW1, db1, dW2, db2, alpha)
        if i % 5 == 0:
            print("Iteration: ", i)
            predictions = get_predictions(A2)
            print(get_accuracy(predictions, Y))
    return W1, b1, W2, b2

i % 5 = 0让我们可以直观的每5次可以看到一次我们的模型准确度,那么梯度下降就到此为止了,我们找到合适的迭代次数(interations)和学习率(alpha)就可以通过这个函数得到一份好的W和b了,最后我们还希望可以从数据集中随便挑出一张图,给出一个预测,让我们直观的来看一眼,这个网络的学习成果。

猜测的过程就是前向传播:

def make_predictions(X, W1, b1, W2, b2):
    _, _, _, A2 = forward_prop(W1, b1, W2, b2, X)
    predictions = get_predictions(A2)
    return predictions

再写一个检验猜测的函数,就要大功告成了:

def test_prediction(index, W1, b1, W2, b2):
    current_image = X_train[:, index, None]
    prediction = make_predictions(X_train[:, index, None], W1, b1, W2, b2)
    label = label_train[index]
    
    print("Prediction: ", prediction)
    print("Label: ", label)

    current_image = current_image.reshape((28, 28)) * 255
    plt.gray()
    plt.imshow(current_image, interpolation='nearest')
    plt.text(3, 3, prediction, fontsize = 22, bbox = dict(facecolor = 'red', alpha = 0.5))
    plt.show()

最后我们只需要这两行代码就可以让这个网络作出判断了:

W1, b1, W2, b2 = gradient_descent(X_train, label_train, 0.10, 350)
test_prediction(0, W1, b1, W2, b2)

上面是alpha取0.1,interations取350的情况,运行结果如下图所示:

相当的完美!

,

3 条回复

  1. Random9 的头像
    Random9

    134本来让我放这个,但是没找到b站下载视频的方法:https://www.bilibili.com/video/BV1m3411m74x/?buvid=XXDC7C9D3EE09CA81CE14132B527997495802&from_spmid=search.search-result.0.0&is_story_h5=false&mid=EX5oOqyoDH2uaD51T6xh7Q%3D%3D&p=1&plat_id=114&share_from=ugc&share_medium=android&share_plat=android&share_session_id=43814523-d548-439b-860b-df2de278dda2&share_source=WEIXIN&share_tag=s_i&spmid=united.player-video-detail.0.0&timestamp=1706290623&unique_k=ZUsMVfT&up_id=287134916

  2. lw2333 的头像
    lw2333

    很不错的文章!

    有几个格式上的地方修改了可能更好看一些qwq

    “`md
    Z是一个k*1的列向量,Z_i,A_i表示Z,A的第i行的值。
    那么A^[1]接下来经过输出层就要变成输出了:
    “`
    没渲染出来;

    以及 fancy math 下面公式字母有点上下交叉,看得有点不顺眼。

    另外,不知道作者对 pytorch 计算图原理科普是否有兴趣… 或可以借此更新一波(?)

    1. Random9 的头像
      Random9

      哈哈哈!感谢支持,第一个问题我不知道发生什么问题了,我好像能看到渲染出来的公式,y\hat的那一行公式就是输出,对于fancy math,我也发现了这个问题,但是latex的行间距我不太会调之后我去研究一下,关于pytorch我了解的比较少,如果以后认真学了有机会我可以搞搞~~

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

© Random9’s Pitlane