lab
Neural Network
简体中文

从零开始打造并训练神经网络

概述

之前已经讲述了不少理论知识了,现在是时候开始实战了。让我们尝试从零开始打造一个神经网络并训练它,把整个过程串起来。


为了更直观、更容易理解,我们遵循以下原则:

  1. 不使用第三方库,让逻辑更加简单;
  2. 不做性能优化:避免引入额外的概念和技巧,增加复杂度;

数据集

首先,我们需要一个数据集。为了方便可视化,我们使用一个二元函数作为目标函数,然后基于它的采样来生成数据集。


注:实际工程项目中,目标函数是未知的,但是可以基于它进行采样。

虚构目标函数

o(x,y)={1x2+y2<10其它o(x, y) = \begin{cases} 1 & x^2 + y^2 < 1 \\ 0 & \text{其它}\end{cases}

代码如下:

def o(x, y): return 1.0 if x*x + y*y < 1 else 0.0

生成数据集

sample_density = 10 xs = [ [-2.0 + 4 * x/sample_density, -2.0 + 4 * y/sample_density] for x in range(sample_density+1) for y in range(sample_density+1) ] dataset = [ (x, y, o(x, y)) for x, y in xs ]

生成的数据为: [[-2.0, -2.0, 0.0], [-2.0, -1.6, 0.0], ...]

图像如下:

数据集图像

构造神经网络

激活函数

import math def sigmoid(x): return 1 / (1 + math.exp(-x))

神经元

from random import seed, random seed(0) class Neuron: def __init__(self, num_inputs): self.weights = [random()-0.5 for _ in range(num_inputs)] self.bias = 0.0 def forward(self, inputs): # z = wx + b z = sum([ i * w for i, w in zip(inputs, self.weights) ]) + self.bias return sigmoid(z)

神经元表达式为:

sigmoid(wx+b)\text{sigmoid}(\mathbf w \mathbf x + b)
  • w\mathbf w:向量,对应代码中的 weights 数组
  • bb: 对应代码中的 bias

注:神经元中的参数都是随机初始化的。但是为了确保可复现实验,一般都固定一个随机种子(seed(0))

神经网络

class MyNet: def __init__(self, num_inputs, hidden_shapes): layer_shapes = hidden_shapes + [1] input_shapes = [num_inputs] + hidden_shapes self.layers = [ [ Neuron(pre_layer_size) for _ in range(layer_size) ] for layer_size, pre_layer_size in zip(layer_shapes, input_shapes) ] def forward(self, inputs): for layer in self.layers: inputs = [ neuron.forward(inputs) for neuron in layer ] # return the output of the last neuron return inputs[0]

构造一个如下神经网络:

net = MyNet(2, [4])

到这里,我们就得到了一个神经网络(net),可以调用其代表的神经网络函数:

print(net.forward([0, 0]))

得到函数值 0.55...,此时的神经网络的是一个未经训练的网络。

初始的神经网络函数图像

训练神经网络

损失函数

首先定义一个损失函数:

def square_loss(predict, target): return (predict-target)**2

计算梯度

梯度的计算是比较复杂的,特别是对于深层神经网络。反向传播算法是一个专门为计算神经网络梯度而生的算法。


由于其比较复杂,这里不展开描述,感兴趣的可以参考下面详细代码。而且现在的深度学习框架中都有自动求梯度的功能。


定义导函数:

def sigmoid_derivative(x): _output = sigmoid(x) return _output * (1 - _output) def square_loss_derivative(predict, target): return 2 * (predict-target)

求偏导数(在 forward 函数中缓存了部分数据,以方便求导):

class Neuron: ... def forward(self, inputs): self.inputs_cache = inputs # z = wx + b self.z_cache = sum([ i * w for i, w in zip(inputs, self.weights) ]) + self.bias return sigmoid(self.z_cache) def zero_grad(self): self.d_weights = [0.0 for w in self.weights] self.d_bias = 0.0 def backward(self, d_a): d_loss_z = d_a * sigmoid_derivative(self.z_cache) self.d_bias += d_loss_z for i in range(len(self.inputs_cache)): self.d_weights[i] += d_loss_z * self.inputs_cache[i] return [d_loss_z * w for w in self.weights] class MyNet: ... def zero_grad(self): for layer in self.layers: for neuron in layer: neuron.zero_grad() def backward(self, d_loss): d_as = [d_loss] for layer in reversed(self.layers): da_list = [ neuron.backward(d_a) for neuron, d_a in zip(layer, d_as) ] d_as = [sum(da) for da in zip(*da_list)]
  • 偏导数分别存储于d_weightsd_bias
  • zero_grad函数用于清空梯度,包括各个偏导数
  • backward函数用于计算偏导数,并将其值累加存储

更新参数

使用梯度下降法更新参数:

class Neuron: ... def update_params(self, learning_rate): self.bias -= learning_rate * self.d_bias for i in range(len(self.weights)): self.weights[i] -= learning_rate * self.d_weights[i] class MyNet: ... def update_params(self, learning_rate): for layer in self.layers: for neuron in layer: neuron.update_params(learning_rate)

执行训练

def one_step(learning_rate): net.zero_grad() loss = 0.0 num_samples = len(dataset) for x, y, z in dataset: predict = net.forward([x, y]) loss += square_loss(predict, z) net.backward(square_loss_derivative(predict, z) / num_samples) net.update_params(learning_rate) return loss / num_samples def train(epoch, learning_rate): for i in range(epoch): loss = one_step(learning_rate) if i == 0 or (i+1) % 100 == 0: print(f"{i+1} {loss:.4f}")

训练 2000 步:

train(2000, learning_rate=10)

注:此处使用了一个比较大的学习率,这个跟项目情况有关。在实际项目中的学习率通常都很小

训练后的神经网络函数图像
log y\text{log y}
loss曲线

推理

训练完成之后,可以利用模型进行推理:

def inference(x, y): return net.forward([x, y]) print(inference(1, 2))

完整的代码请参考:nn_from_scratch.py

总结

本次实战的步骤如下:

  1. 构造了一个虚拟的目标函数: o(x,y)o(x, y)
  2. 基于o(x,y)o(x, y)进行抽样,得到数据集,即数据集函数: d(x,y)d(x, y)
  3. 构造了包含一个隐藏层的全连接神经网络,即神经网络函数: f(x,y)f(x, y)
  4. 使用梯度下降法训练神经网络,让f(x,y)f(x, y)近似d(x,y)d(x, y)

其中最复杂的部分在于求梯度,其中用到了反向传播算法。在实际项目中,使用主流的深度学习框架进行开发,可以省掉求梯度的代码,门槛更低。


实验室的3D 分类实验中,第二个数据集跟本实战非常相似,可以进去实际操作一下。