家人们,我准备开启一个全新的系列,来聊聊——神经网络的原理与实现。
也许你已经听过“神经网络”这个词无数次,它是深度学习的基石,是 ChatGPT、图像识别、自动驾驶背后的关键技术。但你是否真正理解过,神经网络到底是怎么“看懂”图像、听懂语言,甚至学会写代码的?这些“看似智能”的行为,背后到底发生了什么?
这个系列将从最基础的多层感知器(MLP)出发,一步步揭开神经网络的神秘面纱,带你理解:
● 神经网络到底是什么?它是如何“模仿人脑”的?
● 什么是梯度下降?为什么梯度下降能优化模型?
● 什么是反向传播?
● 如何用代码手写一个简单的神经网络,从0开始训练识别手写数字?
这个系列会兼顾原理解释与工程实现,面向所有对 AI 感兴趣的朋友,不管你是小白入门,还是进阶学习,相信都能有所收获。
此外,所有相关源码示例、流程图、模型配置与知识库构建技巧,我也将持续更新在Github:LLMHub,欢迎关注收藏!
本篇文章是该系列的第四篇,也是最后一篇。在了解了什么是神经网络并且知道了神经网络的底层原理之后,我们可以通过实际动手来更加深入的理解神经网络,同时提升自己的编码能力。
下面我们就从0开始编写一个识别手写数字的代码,使用随机梯度下降方法和MNIST训练数据,我们需要做的第一件事是获取 MNIST 数据。可以通过克隆我的代码仓库获取数据git@github.com:zhangting-hit/LLMHub.git,其中同时包含源码和其他知识点。
接下来我们开始编写代码。
首先我们创建一个Network类,也就是神经网络:
class Network(object): def __init__(self, sizes): self.num_layers = len(sizes) self.sizes = sizes self.biases = [np.random.randn(y, 1) for y in sizes[1:]] self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])]
在这段代码中,列表 sizes
表示每一层中神经元的数量。例如,如果我们希望创建一个 Network
对象,其第一层有 2 个神经元,第二层有 3 个神经元,最后一层有 1 个神经元,我们可以这样写代码:
net = Network([2, 3, 1])
在 Network
对象中,偏置(biases)和权重(weights)都是通过 Numpy 的 np.random.randn
函数随机初始化的,该函数会生成均值为 0、标准差为 1 的高斯分布(正态分布)。这种随机初始化为后续的随机梯度下降(stochastic gradient descent, SGD)算法提供了一个起始点。
请注意,初始化代码假设网络的第一层是输入层,因此不会为这一层设置任何偏置项 —— 因为偏置项只用于计算后续层的输出。
还需要注意的是,偏置和权重被存储为Numpy 矩阵组成的列表。例如,net.weights[1]
是一个 Numpy 矩阵,存储了连接第二层和第三层神经元之间的权重(注意:不是第一层和第二层,因为 Python 的列表索引是从 0 开始的)。
我们知道神经网络计算的核心公式是 a′=σ(wa+b),其中用到了激活函数,接下来我们定义激活函数 sigmoid 函数:
def sigmoid(z): return 1.0 / (1.0 + np.exp(-z))
注意:当输入 z
是一个向量或 Numpy 数组时,Numpy 会自动逐元素地(elementwise)应用 sigmoid 函数,也就是说是向量化(vectorized)处理的。
接下来,我们给 Network
类添加一个 feedforward
方法,该方法在给定输入 a
的情况下,返回对应的输出。
def feedforward(self, a): """Return the output of the network if "a" is input.""" for b, w in zip(self.biases, self.weights): a = sigmoid(np.dot(w, a) + b) return a
这个方法所做的就是:对每一层应用公式 a′=σ(wa+b)。
当然,我们希望 Network
对象最重要的能力是能够学习。为此,我们为其添加一个 SGD
方法,该方法实现了小批量随机梯度下降(mini-batch stochastic gradient descent)。以下是代码,某些部分可能有些难懂,但我会在代码后逐段解释:
def SGD(self, training_data, epochs, mini_batch_size, eta, test_data=None): """ 使用小批量随机梯度下降训练神经网络。 training_data 是一个 (x, y) 元组的列表,代表输入和期望输出。 其他参数的含义如下: - epochs:训练轮数; - mini_batch_size:每个小批量的大小; - eta:学习率; - test_data(可选):若提供,每轮训练后用测试集评估模型表现。 """ if test_data: n_test = len(test_data) n = len(training_data) for j in xrange(epochs): random.shuffle(training_data) mini_batches = [ training_data[k:k+mini_batch_size] for k in xrange(0, n, mini_batch_size)] for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta) if test_data: print "Epoch {0}: {1} / {2}".format( j, self.evaluate(test_data), n_test) else: print "Epoch {0} complete".format(j)
工作流程如下:
- 每个 epoch 开始前,对
training_data
进行随机打乱;然后将其划分为多个小批量(mini_batch);对每个小批量,调用 self.update_mini_batch(mini_batch, eta)
,执行一次基于该批数据的梯度下降步骤;如果提供了测试集 test_data
,就输出当前模型在测试集上的准确度。其中update_mini_batch
方法实现如下:
def update_mini_batch(self, mini_batch, eta): """ 使用反向传播算法对单个小批量更新网络的权重和偏置。 mini_batch 是一个 (x, y) 元组的列表; eta 是学习率。 """ nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] for x, y in mini_batch: delta_nabla_b, delta_nabla_w = self.backprop(x, y) nabla_b = [nb + dnb for nb, dnb in zip(nabla_b, delta_nabla_b)] nabla_w = [nw + dnw for nw, dnw in zip(nabla_w, delta_nabla_w)] self.weights = [w - (eta / len(mini_batch)) * nw for w, nw in zip(self.weights, nabla_w)] self.biases = [b - (eta / len(mini_batch)) * nb for b, nb in zip(self.biases, nabla_b)]
上面的关键代码是:
delta_nabla_b, delta_nabla_w = self.backprop(x, y)
这行代码调用了一个名为 backprop
的方法,这个方法实现了反向传播算法。
所以 update_mini_batch
方法的逻辑很简单:
- 对小批量中的每一个训练样本
(x, y)
,使用反向传播计算其梯度;然后将所有样本的梯度加总平均;最后根据平均梯度,更新权重和偏置。def backprop(self, x, y): """返回一个元组 (nabla_b, nabla_w),表示损失函数 C_x 对每层偏置和权重的梯度""" # 初始化 nabla_b 和 nabla_w,结构与 biases 和 weights 相同,元素为 0,用于累计梯度 nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] # 前向传播开始,设置初始输入 activation 为 x activation = x # 存储所有层的激活值(包括输入层) activations = [x] # 存储所有层的加权输入 z(即 z = w·a + b) zs = [] # 遍历每层的权重 w 和偏置 b,执行前向传播 for b, w in zip(self.biases, self.weights): z = np.dot(w, activation) + b # 计算加权输入 z zs.append(z) # 保存 z 值供后向传播使用 activation = sigmoid(z) # 计算当前层的激活值 activations.append(activation) # 保存激活值供后向传播使用 # 反向传播开始 —— 先计算输出层的误差 delta delta = self.cost_derivative(activations[-1], y) * sigmoid_prime(zs[-1]) # 输出层偏置的梯度就是 delta nabla_b[-1] = delta # 输出层权重的梯度是 delta 与上一层激活值的转置的点积 nabla_w[-1] = np.dot(delta, activations[-2].transpose()) # 从倒数第二层开始,逐层向前传播误差 for l in xrange(2, self.num_layers): z = zs[-l] # 当前层的 z sp = sigmoid_prime(z) # 当前层 z 的 sigmoid 导数 delta = np.dot(self.weights[-l+1].transpose(), delta) * sp # 传播误差 nabla_b[-l] = delta # 当前层偏置的梯度 nabla_w[-l] = np.dot(delta, activations[-l-1].transpose()) # 当前层权重的梯度 # 返回每层偏置和权重的梯度 return (nabla_b, nabla_w)def cost_derivative(self, output_activations, y): """返回损失函数对输出层激活值的偏导数,即 ∂C / ∂a""" return (output_activations - y) # 平方损失函数的导数:a - ydef sigmoid_prime(z): """sigmoid 函数的导数,用于反向传播时计算梯度""" return sigmoid(z) * (1 - sigmoid(z)) # 利用公式:σ'(z) = σ(z) * (1 - σ(z))
backprop
方法实现了神经网络的反向传播算法,用于高效计算代价函数对每层权重和偏置的梯度。它首先执行前向传播,将每层的加权输入(z 值)和激活值(a 值)保存下来。然后从输出层开始,利用损失函数的导数(通过 cost_derivative
计算)和 sigmoid 激活函数的导数,计算输出层的误差,并逐层向前传播这个误差。在每一层,通过误差和前一层的激活值计算出当前层权重和偏置的梯度。最终返回的 nabla_b
和 nabla_w
分别是各层偏置和权重的梯度,它们将被用于梯度下降步骤中更新模型参数。整个过程基于链式法则,确保每个参数的梯度都能高效准确地计算出来,是神经网络学习的关键步骤。
如果对上述反向传播的过程或者公式有疑问,可以去看看本系列的什么是反向传播这篇文章。
以下是全部代码:
import randomimport numpy as npclass Network(object): def __init__(self, sizes): self.num_layers = len(sizes) self.sizes = sizes self.biases = [np.random.randn(y, 1) for y in sizes[1:]] self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])] def feedforward(self, a): for b, w in zip(self.biases, self.weights): a = sigmoid(np.dot(w, a)+b) return a def SGD(self, training_data, epochs, mini_batch_size, eta, test_data=None): if test_data: n_test = len(test_data) n = len(training_data) for j in xrange(epochs): random.shuffle(training_data) mini_batches = [ training_data[k:k+mini_batch_size] for k in xrange(0, n, mini_batch_size)] for mini_batch in mini_batches: self.update_mini_batch(mini_batch, eta) if test_data: print "Epoch {0}: {1} / {2}".format( j, self.evaluate(test_data), n_test) else: print "Epoch {0} complete".format(j) def update_mini_batch(self, mini_batch, eta): """Update the network's weights and biases by applying gradient descent using backpropagation to a single mini batch. The ``mini_batch`` is a list of tuples ``(x, y)``, and ``eta`` is the learning rate.""" nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] for x, y in mini_batch: delta_nabla_b, delta_nabla_w = self.backprop(x, y) nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)] nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)] self.weights = [w-(eta/len(mini_batch))*nw for w, nw in zip(self.weights, nabla_w)] self.biases = [b-(eta/len(mini_batch))*nb for b, nb in zip(self.biases, nabla_b)] def backprop(self, x, y): """返回一个元组 (nabla_b, nabla_w),表示损失函数 C_x 对每层偏置和权重的梯度""" # 初始化 nabla_b 和 nabla_w,结构与 biases 和 weights 相同,元素为 0,用于累计梯度 nabla_b = [np.zeros(b.shape) for b in self.biases] nabla_w = [np.zeros(w.shape) for w in self.weights] # 前向传播开始,设置初始输入 activation 为 x activation = x # 存储所有层的激活值(包括输入层) activations = [x] # 存储所有层的加权输入 z(即 z = w·a + b) zs = [] # 遍历每层的权重 w 和偏置 b,执行前向传播 for b, w in zip(self.biases, self.weights): z = np.dot(w, activation) + b # 计算加权输入 z zs.append(z) # 保存 z 值供后向传播使用 activation = sigmoid(z) # 计算当前层的激活值 activations.append(activation) # 保存激活值供后向传播使用 # 反向传播开始 —— 先计算输出层的误差 delta delta = self.cost_derivative(activations[-1], y) * sigmoid_prime(zs[-1]) # 输出层偏置的梯度就是 delta nabla_b[-1] = delta # 输出层权重的梯度是 delta 与上一层激活值的转置的点积 nabla_w[-1] = np.dot(delta, activations[-2].transpose()) # 从倒数第二层开始,逐层向前传播误差 for l in xrange(2, self.num_layers): z = zs[-l] # 当前层的 z sp = sigmoid_prime(z) # 当前层 z 的 sigmoid 导数 delta = np.dot(self.weights[-l+1].transpose(), delta) * sp # 传播误差 nabla_b[-l] = delta # 当前层偏置的梯度 nabla_w[-l] = np.dot(delta, activations[-l-1].transpose()) # 当前层权重的梯度 # 返回每层偏置和权重的梯度 return (nabla_b, nabla_w) def evaluate(self, test_data): """Return the number of test inputs for which the neural network outputs the correct result. Note that the neural network's output is assumed to be the index of whichever neuron in the final layer has the highest activation.""" test_results = [(np.argmax(self.feedforward(x)), y) for (x, y) in test_data] return sum(int(x == y) for (x, y) in test_results) def cost_derivative(self, output_activations, y): """返回损失函数对输出层激活值的偏导数,即 ∂C / ∂a""" return (output_activations - y) # 平方损失函数的导数:a - ydef sigmoid(z): """The sigmoid function.""" return 1.0/(1.0+np.exp(-z))def sigmoid_prime(z): """sigmoid 函数的导数,用于反向传播时计算梯度""" return sigmoid(z) * (1 - sigmoid(z)) # 利用公式:σ'(z) = σ(z) * (1 - σ(z))
核心代码就是上面这些,下面就可以开始训练并进行测试了,代码如下:
import mnist_loaderimport network# 加载 MNIST 数据集(784维输入,10维输出one-hot)training_data, validation_data, test_data = mnist_loader.load_data_wrapper()# 创建一个 3 层神经网络:输入层784,隐藏层30,输出层10net = network.Network([784, 30, 10])# 使用小批量随机梯度下降训练:30个epoch,每批10张图,学习率为3.0net.SGD(training_data, 30, 10, 3.0, test_data=test_data)# ----------------------------# 测试部分:计算在 test_data 上的准确率# ----------------------------def test_accuracy(net, test_data): """评估网络在测试集上的准确率""" test_results = [(int(net.feedforward(x).argmax()), y) for (x, y) in test_data] correct = sum(int(pred == label) for (pred, label) in test_results) total = len(test_data) print(f"Test Accuracy: {correct} / {total} ({100.0 * correct / total:.2f}%)")# 调用测试函数test_accuracy(net, test_data)
可以看到上图中的运行结果,测试数据集上的准确率达到了将近95%,说明神经网络在手写数字识别任务中具有很好的效果。
以上就是我们从0开始编写手写数字识别神经网络的所有内容了,到这里我们的神经网络系列也到此结束啦!我们从以MLP为例介绍什么是神经网络开始,依次介绍了什么是梯度下降,什么又是反向传播,最后我们经过实战更加深入的理解了神经网络的原理和实现。希望本系列能使大家有所收获,如果大家对什么知识点感兴趣,可以给我留言!
码字不易,如果对你有一点帮助,点个喜欢吧!
关于深度学习和大模型相关的知识和前沿技术更新,请关注公众号算法coting
!
以上内容参考了
Neural Networks and Deep Learning
非常感谢,如有侵权请联系删除!