用Python从零开始构建一个神经网络

 

用Python从零开始构建一个神经网络

2023年西安电子科技大学认知计算作业
潘笃驿 21009201106

下面的内容旨在帮助你完成从零构建一个神经网络,内容基础来自于victorzhou的一篇blog:Machine Learning for Beginners: An Introduction to Neural Networks
在上文基础上对内容做了优化

1. 构建基本的神经元

神经元往往是一个神经网络最基础的组成部分,一个神经元的任务是,它应该接受一个输入,然后根据内部的权重来做一个输出,本质上是一个数学模型,下面的内容会展示一个非常简单的神经元,它接受一个二维输入,然后输出一维结果

该神经元内部发生的计算过程如下:
首先每一维的特征需要与对应的权重值相乘,这里的权重就指 $w_1$ \(x_1 \rightarrow x_1 * w_1\) \(x_2 \rightarrow x_2 * w_2\)

接下来当权重计算完成后,我们需要加入一个偏置值 $bias$ 在这里写为 $b$ : \((x_1 * w_1) + (x_2 * w_2) + b\)

最后得到的结果将经过一个激活函数 $f$ ,在后面的演示中,激活函数我们将采用$sigmod$: \(y = f(x_1 * w_1 + x_2 * w_2 + b)\)

sigmod 函数的图像如下,它的输出范围为 $(0, 1)$,可以将 $(-\infty, +\infty)$ 的输出范围 缩小到 $(0, 1)$ 非常适合完成二分类任务

一个简单示例

假设按照我们的设计存在这样一个神经元,权重为 [0, 1] ,偏置值为 4

\(w = [0, 1]\) \(b = 4\)

当输入为 $x = [2, 3]$. 它会进行如下的计算: \(\begin{aligned} (w \cdot x) + b &= ((w_1 * x_1) + (w_2 * x_2)) + b \\ &= 0 * 2 + 1 * 3 + 4 \\ &= 7 \\ \end{aligned}\)

\[y = f(w \cdot x + b) = f(7) = \boxed{0.999}\]

最后我们得到的结果是 $0.999$ ,这样的一个完整的过程,我们称它为前向计算。

### Python代码实现一个简单的神经元
import numpy as np

def sigmoid(x):
  # 激活函数的表达式: f(x) = 1 / (1 + e^(-x))
  return 1 / (1 + np.exp(-x))

class Neuron:
  def __init__(self, weights, bias):
    self.weights = weights
    self.bias = bias

  def feedforward(self, inputs):
    # 与权重相乘,再加上偏置值,最后经过激活函数
    total = np.dot(self.weights, inputs) + self.bias
    return sigmoid(total)

weights = np.array([0, 1]) # w1 = 0, w2 = 1
bias = 4                   # b = 4
n = Neuron(weights, bias)

x = np.array([2, 3])       # x1 = 2, x2 = 3
print(n.feedforward(x))    # 0.9990889488055994
0.9990889488055994

2. 将神经元组装为一个神经网络

一个复杂的神经网络往往由许多个神经元连接组成,而这些神经元往往可以看成一层一层的结构,一个简单的神经网络结构如下:

该网络有 2 个输入,一个带有 2 个神经元的隐藏层 ($h_1$ and $h_2$), 以及一个具有 1 个神经元的输出层 ($o_1$).

隐藏层是输入(第一)层和输出(最后)层之间的任何层。可以有多个隐藏层

神经网络中的前向计算

让我们使用上图所示的网络并假设所有神经元具有相同的权重 $w = [0, 1]$ , 偏置值 $b = 0$ , 并且同样使用sigmod来作为激活函数.

来看看输入 $x = [2, 3]$ 会发生什么

\[\begin{aligned} h_1 = h_2 &= f(w \cdot x + b) \\ &= f((0 * 2) + (1 * 3) + 0) \\ &= f(3) \\ &= 0.9526 \\ \end{aligned}\] \[\begin{aligned} o_1 &= f(w \cdot [h_1, h_2] + b) \\ &= f((0 * h_1) + (1 * h_2) + 0) \\ &= f(0.9526) \\ &= \boxed{0.7216} \\ \end{aligned}\]

我们输入了 $x = [2, 3]$ 得到了神经网络的计算值为 $0.7216$.

神经网络可以有任意数量的层,这些层中可以有任意数量的神经元。基本思想保持不变:通过网络中的神经元向前馈送输入,以最终获得输出。为简单起见,我们将在本文的其余部分继续使用上图所示的网络。

Python代码实现实现简单的神经网络

结构参考如下:

class OurNeuralNetwork:
  '''
  A neural network with:
    - 2 inputs
    - a hidden layer with 2 neurons (h1, h2)
    - an output layer with 1 neuron (o1)
  Each neuron has the same weights and bias:
    - w = [0, 1]
    - b = 0
  '''
  def __init__(self):
    weights = np.array([0, 1])
    bias = 0

    # 这里的神经元我们使用上一小节定义的神经元
    self.h1 = Neuron(weights, bias)
    self.h2 = Neuron(weights, bias)
    self.o1 = Neuron(weights, bias)

  def feedforward(self, x):
    out_h1 = self.h1.feedforward(x)
    out_h2 = self.h2.feedforward(x)
    out_o1 = self.o1.feedforward(np.array([out_h1, out_h2]))

    return out_o1

network = OurNeuralNetwork()
x = np.array([2, 3])
print(network.feedforward(x)) # 0.7216325609518421
0.7216325609518421

3. 训练我们设计的神经网络, Part 1 Loss

现在我们有如下的数据信息:

Name Weight (lb) Height (in) Gender
Alice 133 65 F
Bob 160 72 M
Charlie 152 70 M
Diana 120 60 F

我们现在希望能够训练一个模型,根据用户输入的特征,比如 180身高80公斤的体重 来得出一个人的性别
结构大概如下图:

为了方便训练,我们将女性性别表示为 $1$ ,将男性性别表示为 $0$,

Name Weight (minus 135) Height (minus 66) Gender
Alice -2 -1 1
Bob 25 6 0
Charlie 17 4 0
Diana -15 -6 1

这里不会过多的介绍为什么这样处理训练数据,而且这里的数据处理非常简单,如果您有更多的兴趣,可以在网络上获取更多关于数据预处理操作的信息

损失

在训练网络之前,我们首先需要一种方法来量化它的程度,以便它可以尝试做得更好。这就是损失函数。

我们将使用均方误差(MSE)损失::

\[\text{MSE} = \frac{1}{n} \sum_{i=1}^n (y_{true} - y_{pred})^2\]

Let’s break this down:

  • $n$ 是样本数, w为 $4$ (Alice, Bob, Charlie, Diana)。
  • $y$ 代表被预测的变量, 也就是性别(0,1)。
  • $y_{true}$ 是变量的真实值(“正确答案”)。也就是说 $y_{true}$ 当输入 Alice 的特征时,结果应该是 $1$ (Female)。
  • $y_{pred}$ 是变量的预测值。这就是我们的网络输出结果。

$(y_{true} - y_{pred})^2$ 称为均方误差。我们的损失函数只是取所有平方误差的平均值(因此称为均方误差)。我们的预测越好,我们的损失就越低

通常越小的loss值就代表模型现在的能力越强(不考虑过拟合)

训练模型的过程就是希望最小化它的损失

Loss计算的示例

假设我们的网络总是输出0 换句话说,它确信所有人类都是男性🤔。我们的损失是什么?

Name $y_{true}$ $y_{pred}$ $(y_{true} - y_{pred})^2$
Alice 1 0 1
Bob 0 0 0
Charlie 0 0 0
Diana 1 0 1
\[\text{MSE} = \frac{1}{4} (1 + 0 + 0 + 1) = \boxed{0.5}\]

Python代码实现 MSE Loss

import numpy as np

def mse_loss(y_true, y_pred):
  # y_true and y_pred are numpy arrays of the same length.
  return ((y_true - y_pred) ** 2).mean()

y_true = np.array([1, 0, 0, 1])
y_pred = np.array([0, 0, 0, 0])

print(mse_loss(y_true, y_pred)) # 0.5

4. 训练我们设计的神经网络, Part 2 反向传播, 优化神经网络

我们现在有一个明确的目标:最小化神经网络的损失。我们知道我们可以改变网络的权重和偏差来影响其预测,但网络权重与偏差的改变不可能由我们随心所欲地来进行,我们希望整个过程损失是不断减少的,那如何做到这一点呢?

为了简单起见,我们假设数据集中只有 Alice:

Name Weight (minus 135) Height (minus 66) Gender
Alice -2 -1 1

那么均方误差损失就是 Alice 的平方误差:

\[\begin{aligned} \text{MSE} &= \frac{1}{1} \sum_{i=1}^1 (y_{true} - y_{pred})^2 \\ &= (y_{true} - y_{pred})^2 \\ &= (1 - y_{pred})^2 \\ \end{aligned}\]

考虑损失的另一种方法是作为权重和偏差的函数。让我们标记网络中的每个权重和偏差:

然后,我们可以将损失写成多变量函数:

\[L(w_1, w_2, w_3, w_4, w_5, w_6, b_1, b_2, b_3)\]

想象一下我们想要调整 $w_1$. 那当我们调整 $w_1$时,$L$ 会如何变化呢? 我们可以用偏导数来观察$L$的变化 $\frac{\partial L}{\partial w_1}$

利用链式法则,我们可以将 $\frac{\partial y_{pred}}{\partial w_1}$ 重写为:

\[\frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial y_{pred}} * \frac{\partial y_{pred}}{\partial w_1}\]

我们可以计算 $\frac{\partial L}{\partial y_{pred}}$ 因为我们已经计算过 $L = (1 - y_{pred})^2$ :

\[\frac{\partial L}{\partial y_{pred}} = \frac{\partial (1 - y_{pred})^2}{\partial y_{pred}} = \boxed{-2(1 - y_{pred})}\]

接下来我们研究如何计算 $\frac{\partial y_{pred}}{\partial w_1}$. 我们用 $h_1, h_2, o_1$ 来代表神经网络每层的输出

\(y_{pred} = o_1 = f(w_5h_1 + w_6h_2 + b_3)\)

f 表示激活函数 sigmoid

参数 $w_1$ 只影响 $h_1$ (not $h_2$), 所以我们可以将计算式写成

\[\frac{\partial y_{pred}}{\partial w_1} = \frac{\partial y_{pred}}{\partial h_1} * \frac{\partial h_1}{\partial w_1}\] \[\frac{\partial y_{pred}}{\partial h_1} = \boxed{w_5 * f'(w_5h_1 + w_6h_2 + b_3)}\]

同样的,我们也可以对 $\frac{\partial h_1}{\partial w_1}$ 使用链式法则:

\[h_1 = f(w_1x_1 + w_2x_2 + b_1)\] \[\frac{\partial h_1}{\partial w_1} = \boxed{x_1 * f'(w_1x_1 + w_2x_2 + b_1)}\]

$x_1$ 代表体重, and $x_2$ 代表身高. 接下来我们来观察sigmod函数的导数

\[f(x) = \frac{1}{1 + e^{-x}}\] \[f'(x) = \frac{e^{-x}}{(1 + e^{-x})^2} = f(x) * (1 - f(x))\]

现在我们已经将最开始的 $\frac{\partial L}{\partial w_1}$ 拆解为下面这个我们可以计算的式子:

\[\boxed{\frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial y_{pred}} * \frac{\partial y_{pred}}{\partial h_1} * \frac{\partial h_1}{\partial w_1}}\]

这种通过向后计算偏导数的系统称为反向传播

如何计算偏导数

我们将继续假设只有 Alice 在我们的数据集中

Name Weight (minus 135) Height (minus 66) Gender
Alice -2 -1 1

让我们将所有权重初始化为 $1$ 所有的偏置设置为 $0$. 当我们进行前向计算,我们会得到下面的结果:

\[\begin{aligned} h_1 &= f(w_1x_1 + w_2x_2 + b_1) \\ &= f(-2 + -1 + 0) \\ &= 0.0474 \\ \end{aligned}\] \[h_2 = f(w_3x_1 + w_4x_2 + b_2) = 0.0474\] \[\begin{aligned} o_1 &= f(w_5h_1 + w_6h_2 + b_3) \\ &= f(0.0474 + 0.0474 + 0) \\ &= 0.524 \\ \end{aligned}\]

输出的结果为 $y_{pred} = 0.524$, 这不能够帮助我们准确的分辨出输入的是男性特征($0$)还是女性特征($1$) 。接下来让我们计算偏导 $\frac{\partial L}{\partial w_1}$:

\[\frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial y_{pred}} * \frac{\partial y_{pred}}{\partial h_1} * \frac{\partial h_1}{\partial w_1}\] \[\begin{aligned} \frac{\partial L}{\partial y_{pred}} &= -2(1 - y_{pred}) \\ &= -2(1 - 0.524) \\ &= -0.952 \\ \end{aligned}\] \[\begin{aligned} \frac{\partial y_{pred}}{\partial h_1} &= w_5 * f'(w_5h_1 + w_6h_2 + b_3) \\ &= 1 * f'(0.0474 + 0.0474 + 0) \\ &= f(0.0948) * (1 - f(0.0948)) \\ &= 0.249 \\ \end{aligned}\] \[\begin{aligned} \frac{\partial h_1}{\partial w_1} &= x_1 * f'(w_1x_1 + w_2x_2 + b_1) \\ &= -2 * f'(-2 + -1 + 0) \\ &= -2 * f(-3) * (1 - f(-3)) \\ &= -0.0904 \\ \end{aligned}\] \[\begin{aligned} \frac{\partial L}{\partial w_1} &= -0.952 * 0.249 * -0.0904 \\ &= \boxed{0.0214} \\ \end{aligned}\]

计算的偏导大于0,这告诉我们 增大$w_1$, $L$ 也会增大,所以我们需要减小 $w_1$

随机梯度下降

我们现在基本已经完成了训练的所有准备! 我们将使用一种称为随机梯度下降(SGD)的优化算法,它告诉我们如何改变权重和偏差以最小化损失。基本上就是这个更新方程:

\[w_1 \leftarrow w_1 - \eta \frac{\partial L}{\partial w_1}\]

$\eta$ 是一个称为学习率的常数,它控制我们训练的速度。我们所做的只是让 $w_1$ 减去 $\eta \frac{\partial L}{\partial w_1}$ :

  • 如果 $\frac{\partial L}{\partial w_1}$ 大于0, $w_1$ 将会减小, 使得 $L$ 下降.
  • 如果 $\frac{\partial L}{\partial w_1}$ 小于0, $w_1$ 将会增大, 使得 $L$ 下降.

如果我们对网络中的每个权重和偏置都这样做,损失就会慢慢减少,我们的网络也会得到改善。

我们的训练过程将如下所示:

  1. 从数据集中随机采样,这里我们只随机采取一个样本。
  2. 计算损失相对于权重或偏差的所有偏导数 (e.g. $\frac{\partial L}{\partial w_1}$, $\frac{\partial L}{\partial w_2}$, etc).
  3. 使用SGD来更新每个权重和偏差
  4. 重复步骤1

实现完整的BP神经网络

训练数据如下:

Name Weight (minus 135) Height (minus 66) Gender
Alice -2 -1 1
Bob 25 6 0
Charlie 17 4 0
Diana -15 -6 1

import numpy as np

def sigmoid(x):
  # Sigmoid activation function: f(x) = 1 / (1 + e^(-x))
  return 1 / (1 + np.exp(-x))

def deriv_sigmoid(x):
  # Derivative of sigmoid: f'(x) = f(x) * (1 - f(x))
  fx = sigmoid(x)
  return fx * (1 - fx)

def mse_loss(y_true, y_pred):
  # y_true and y_pred are numpy arrays of the same length.
  return ((y_true - y_pred) ** 2).mean()

class OurNeuralNetwork:
  '''
  A neural network with:
    - 2 inputs
    - a hidden layer with 2 neurons (h1, h2)
    - an output layer with 1 neuron (o1)

  *** DISCLAIMER ***:
  The code below is intended to be simple and educational, NOT optimal.
  Real neural net code looks nothing like this. DO NOT use this code.
  Instead, read/run it to understand how this specific network works.
  '''
  def __init__(self):
    # Weights
    self.w1 = np.random.normal()
    self.w2 = np.random.normal()
    self.w3 = np.random.normal()
    self.w4 = np.random.normal()
    self.w5 = np.random.normal()
    self.w6 = np.random.normal()

    # Biases
    self.b1 = np.random.normal()
    self.b2 = np.random.normal()
    self.b3 = np.random.normal()

  def feedforward(self, x):
    # x is a numpy array with 2 elements.
    h1 = sigmoid(self.w1 * x[0] + self.w2 * x[1] + self.b1)
    h2 = sigmoid(self.w3 * x[0] + self.w4 * x[1] + self.b2)
    o1 = sigmoid(self.w5 * h1 + self.w6 * h2 + self.b3)
    return o1

  def train(self, data, all_y_trues):
    '''
    - data is a (n x 2) numpy array, n = # of samples in the dataset.
    - all_y_trues is a numpy array with n elements.
      Elements in all_y_trues correspond to those in data.
    '''
    learn_rate = 0.1
    epochs = 1000 # number of times to loop through the entire dataset

    for epoch in range(epochs):
      for x, y_true in zip(data, all_y_trues):
        # --- Do a feedforward (we'll need these values later)
        sum_h1 = self.w1 * x[0] + self.w2 * x[1] + self.b1
        h1 = sigmoid(sum_h1)

        sum_h2 = self.w3 * x[0] + self.w4 * x[1] + self.b2
        h2 = sigmoid(sum_h2)

        sum_o1 = self.w5 * h1 + self.w6 * h2 + self.b3
        o1 = sigmoid(sum_o1)
        y_pred = o1

        # --- Calculate partial derivatives.
        # --- Naming: d_L_d_w1 represents "partial L / partial w1"
        d_L_d_ypred = -2 * (y_true - y_pred)

        # Neuron o1
        d_ypred_d_w5 = h1 * deriv_sigmoid(sum_o1)
        d_ypred_d_w6 = h2 * deriv_sigmoid(sum_o1)
        d_ypred_d_b3 = deriv_sigmoid(sum_o1)

        d_ypred_d_h1 = self.w5 * deriv_sigmoid(sum_o1)
        d_ypred_d_h2 = self.w6 * deriv_sigmoid(sum_o1)

        # Neuron h1
        d_h1_d_w1 = x[0] * deriv_sigmoid(sum_h1)
        d_h1_d_w2 = x[1] * deriv_sigmoid(sum_h1)
        d_h1_d_b1 = deriv_sigmoid(sum_h1)

        # Neuron h2
        d_h2_d_w3 = x[0] * deriv_sigmoid(sum_h2)
        d_h2_d_w4 = x[1] * deriv_sigmoid(sum_h2)
        d_h2_d_b2 = deriv_sigmoid(sum_h2)

        # --- Update weights and biases
        # Neuron h1
        self.w1 -= learn_rate * d_L_d_ypred * d_ypred_d_h1 * d_h1_d_w1
        self.w2 -= learn_rate * d_L_d_ypred * d_ypred_d_h1 * d_h1_d_w2
        self.b1 -= learn_rate * d_L_d_ypred * d_ypred_d_h1 * d_h1_d_b1

        # Neuron h2
        self.w3 -= learn_rate * d_L_d_ypred * d_ypred_d_h2 * d_h2_d_w3
        self.w4 -= learn_rate * d_L_d_ypred * d_ypred_d_h2 * d_h2_d_w4
        self.b2 -= learn_rate * d_L_d_ypred * d_ypred_d_h2 * d_h2_d_b2

        # Neuron o1
        self.w5 -= learn_rate * d_L_d_ypred * d_ypred_d_w5
        self.w6 -= learn_rate * d_L_d_ypred * d_ypred_d_w6
        self.b3 -= learn_rate * d_L_d_ypred * d_ypred_d_b3

      # --- Calculate total loss at the end of each epoch
      if epoch % 10 == 0:
        y_preds = np.apply_along_axis(self.feedforward, 1, data)
        loss = mse_loss(all_y_trues, y_preds)
        print("Epoch %d loss: %.3f" % (epoch, loss))

# Define dataset
data = np.array([
  [-2, -1],  # Alice
  [25, 6],   # Bob
  [17, 4],   # Charlie
  [-15, -6], # Diana
])
all_y_trues = np.array([
  1, # Alice
  0, # Bob
  0, # Charlie
  1, # Diana
])

# Train our neural network!
network = OurNeuralNetwork()
network.train(data, all_y_trues)

随着网络的不断学习,损失不断下降:


下面我们就能看到预测的结果:

emily = np.array([-7, -3]) # 128 pounds, 63 inches
frank = np.array([20, 2])  # 155 pounds, 68 inches
print("Emily: %.3f" % network.feedforward(emily)) # 0.951 - F
print("Frank: %.3f" % network.feedforward(frank)) # 0.039 - M