了解如何在没有框架帮助的情况下构建神经网络的基础知识,这些框架可能使其更易于使用
在 Python 中创建具有不同架构的复杂神经网络应该是任何机器学习工程师或数据科学家的标准做法。但是,真正了解神经网络的工作原理同样有价值。在本文中,您将了解如何在没有框架帮助的情况下构建神经网络的基础知识,这些框架可能使其更易于使用。
在阅读本文时,您可以在GitHub的并同时运行代码。
先决条件
在本文中,我将解释如何通过实现前向和后向传递(反向传播)来构建基本的深度神经网络。这需要一些关于神经网络功能的具体知识。
了解线性代数的基础知识也很重要,这样才能理解我为什么要在本文中执行某些运算。我最好的建议是看 3Blue1Brown 的系列线性代数的本质.
NumPy
在本文中,我构建了一个包含 4 层的基本深度神经网络:1 个输入层、2 个隐藏层和 1 个输出层。所有层都是完全连接的。我正在尝试使用一个名为MNIST.该数据集由 70,000 张图像组成,每张图像的尺寸为 28 x 28 像素。数据集包含每个图像的一个标签,该标签指定我在每张图像中看到的数字。我说有 10 个类,因为我有 10 个标签。
MNIST 数据集中的 10 个数字示例,放大 2 倍
为了训练神经网络,我使用随机梯度下降,这意味着我一次将一张图像通过神经网络。
让我们尝试以精确的方式定义图层。为了能够对数字进行分类,在运行神经网络后,您必须最终获得属于某个类别的图像的概率,因为这样您就可以量化神经网络的性能。
-
输入层:在此层中,我输入由 28x28 图像组成的数据集。我将这些图像展平成一个包含 28×28=78428×28=784 个元素的数组。这意味着输入层将有 784 个节点。
-
隐藏层 1:在此层中,我将输入层中的节点数从 784 个减少到 128 个节点。当你在神经网络中前进时,这会带来一个挑战(我稍后会解释这一点)。
-
隐藏层 2:在这一层中,我决定从第一个隐藏层的 128 个节点开始使用 64 个节点。这并不是什么新挑战,因为我已经减少了第一层的数量。
-
输出层:在这一层中,我将 64 个节点减少到总共 10 个节点,以便我可以根据标签评估节点。此标签以包含 10 个元素的数组的形式接收,其中一个元素为 1,其余元素为 0。
您可能意识到,每层中的节点数从 784 个节点减少到 128 个节点,从 64 个节点减少到 10 个节点。这是基于经验观察这会产生更好的结果,因为我们既没有过度拟合,也没有过度拟合,只是试图获得正确数量的节点。本文选择的具体节点数是随机选择的,但为了避免过度拟合,节点数量会减少。在大多数现实生活中,您可能希望通过蛮力或良好的猜测(通常是通过网格搜索或随机搜索)来优化这些参数,但这超出了本文的讨论范围。
导入和数据集
对于整个 NumPy 部分,我特别想分享使用的导入。请注意,我使用 NumPy 以外的库来更轻松地加载数据集,但它们不用于任何实际的神经网络。
from sklearn.datasets import fetch_openml from keras.utils.np_utils import to_categorical import numpy as np from sklearn.model_selection import train_test_split import time
显示更多
现在,我必须加载数据集并对其进行预处理,以便可以在 NumPy 中使用它。我通过将所有图像除以 255 来进行归一化,并使所有图像的值都在 0 - 1 之间,因为这消除了以后激活函数的一些数值稳定性问题。我使用独热编码标签,因为我可以更轻松地从神经网络的输出中减去这些标签。我还选择将输入加载为 28 * 28 = 784 个元素的扁平化数组,因为这是输入层所需要的。
x, y = fetch_openml('mnist_784', version=1, return_X_y=True) x = (x/255).astype('float32') y = to_categorical(y) x_train, x_val, y_train, y_val = train_test_split(x, y, test_size=0.15, random_state=42)
显示更多
初始化
神经网络中权重的初始化有点难考虑。要真正理解以下方法的工作原理和原因,您需要掌握线性代数,特别是使用点积运算时的维数。
尝试实现前馈神经网络时出现的具体问题是,我们试图从 784 个节点转换为 10 个节点。在实例化类时,我传入一个大小数组,该数组定义了每一层的激活次数。DeepNeuralNetwork
dnn = DeepNeuralNetwork(sizes=[784, 128, 64, 10])
显示更多
这将通过函数初始化类。DeepNeuralNetwork``init
def __init__(self, sizes, epochs=10, l_rate=0.001):self.sizes = sizesself.epochs = epochsself.l_rate = l_rate # we save all parameters in the neural network in this dictionaryself.params = self.initialization()
显示更多
让我们看看在调用函数时大小如何影响神经网络的参数。我正在准备“可点”的 m x n 矩阵,以便我可以进行前向传递,同时随着层的增加减少激活次数。我只能对两个矩阵 M1 和 M2 使用点积运算,其中 M1 中的 m 等于 M2 中的 n,或者 M1 中的 n 等于 M2 中的 m。initialization()
通过这个解释,您可以看到我用 m=128m=128 和 n=784n=784 初始化了第一组权重 W1,而接下来的权重 W2 是 m=64m=64 和 n=128n=128。如前所述,输入层 A0 中的激活次数等于 784,当我将激活 A0 点在 W1 上时,操作成功。
def initialization(self):# number of nodes in each layerinput_layer=self.sizes[0]hidden_1=self.sizes[1]hidden_2=self.sizes[2]output_layer=self.sizes[3] params = {'W1':np.random.randn(hidden_1, input_layer) * np.sqrt(1. / hidden_1),'W2':np.random.randn(hidden_2, hidden_1) * np.sqrt(1. / hidden_2),'W3':np.random.randn(output_layer, hidden_2) * np.sqrt(1. / output_layer)} return params
显示更多
前馈
前向传递由 NumPy 中的点运算组成,结果证明它只是矩阵乘法。我必须将权重乘以前一层的激活。然后,我必须将激活函数应用于结果。
为了完成每一层,我依次应用点运算,然后应用 sigmoid 激活函数。在最后一层中,我使用激活函数,因为我想拥有每个类的概率,以便我可以测量当前前向传递的性能。softmax
注意:我选择了该函数的数值稳定版本。您可以从斯坦福大学的课程中内容softmax
CS231n.
def forward_pass(self, x_train):params = self.params # input layer activations becomes sampleparams['A0'] = x_train # input layer to hidden layer 1params['Z1'] = np.dot(params["W1"], params['A0'])params['A1'] = self.sigmoid(params['Z1']) # hidden layer 1 to hidden layer 2params['Z2'] = np.dot(params["W2"], params['A1'])params['A2'] = self.sigmoid(params['Z2']) # hidden layer 2 to output layerparams['Z3'] = np.dot(params["W3"], params['A2'])params['A3'] = self.softmax(params['Z3']) return params['A3']
显示更多
以下代码显示了本文中使用的激活函数。可以看出,我提供了 sigmoid 的衍生版本,因为稍后通过神经网络反向传播时需要它。
def sigmoid(self, x, derivative=False):if derivative:return (np.exp(-x))/((np.exp(-x)+1)**2)return 1/(1 + np.exp(-x)) def softmax(self, x):# Numerically stable with large exponentialsexps = np.exp(x - x.max())return exps / np.sum(exps, axis=0)
显示更多
反向传播
向后传递很难正确,因为必须对齐的大小和操作才能使所有操作成功。这是向后传递的完整功能。我在下面介绍每个重量更新。
def backward_pass(self, y_train, output):'''This is the backpropagation algorithm, for calculating the updatesof the neural network's parameters.'''params = self.paramschange_w = {} # Calculate W3 updateerror = output - y_trainchange_w['W3'] = np.dot(error, params['A3']) # Calculate W2 updateerror = np.multiply( np.dot(params['W3'].T, error), self.sigmoid(params['Z2'], derivative=True) )change_w['W2'] = np.dot(error, params['A2']) # Calculate W1 updateerror = np.multiply( np.dot(params['W2'].T, error), self.sigmoid(params['Z1'], derivative=True) )change_w['W1'] = np.dot(error, params['A1']) return change_w
显示更多
W3 更新
的更新可以通过减去具有从称为 的前向传递的输出中调用的标签的真值数组来计算。此操作成功,因为是 10 并且也是 10。下面的代码可能是一个示例,其中 1 对应于 .W3``y_train``output``len(y_train)``len(output)``y_train``output
y_train = np.array([0, 0, 1, 0, 0, 0, 0, 0, 0, 0])
显示更多
下面的代码显示了一个示例,其中数字是对应于 的类的概率。output``y_train
output = np.array([0.2, 0.2, 0.5, 0.3, 0.6, 0.4, 0.2, 0.1, 0.3, 0.7])
显示更多
如果减去它们,则得到以下结果。
>>> output - y_train array([ 0.2, 0.2, -0.5, 0.3, 0.6, 0.4, 0.2, 0.1, 0.3, 0.7])
显示更多
下一个操作是点操作,它将错误(我刚刚计算的)与最后一层的激活点在一起。
error = output - y_train change_w['W3'] = np.dot(error, params['A3'])
显示更多
W2 更新
接下来是更新权重。为了成功,需要涉及更多的操作。首先,形状略有不匹配,因为具有形状和具有,即完全相同的尺寸。因此,我可以使用W2``W3``(10, 64)``error``(10, 64)
转置操作这样数组的维度就会置换,并且形状现在会对齐以进行点操作。W3``.T
转置操作的示例。左:原始矩阵。右:置换矩阵
W3`现在有 shape 和 has shape ,它们与点运算兼容。结果是`(64, 10)``error``(10, 64)`[逐个元素相乘](https://docs.scipy.org/doc/numpy/reference/generated/numpy.multiply.html)(也称为 Hadamard 积)与 的 sigmoid 函数的导数的结果。最后,我将错误与上一层的激活点在一起。`Z2 error = np.multiply( np.dot(params['W3'].T, error), self.sigmoid(params['Z2'], derivative=True) ) change_w['W2'] = np.dot(error, params['A2'])
W1 更新
同样,用于更新的代码提前一步使用神经网络的参数。除其他参数外,代码等同于 W2 更新。W1
error = np.multiply( np.dot(params['W2'].T, error), self.sigmoid(params['Z1'], derivative=True) ) change_w['W1'] = np.dot(error, params['A1'])