引言

从零开始实现一个神经网络,是理解深度学习基础的最佳途径之一。本文将仅依赖 NumPy 和基础线性代数,手把手构建一个三层前馈神经网络(输入层 → 隐藏层 → 输出层),并在 MNIST 手写数字数据集上完成训练与评估。

全文分为两大部分:

  • 理论篇:介绍三层网络的结构、前向传播机制、常用激活函数(sigmoid、softmax)、交叉熵损失,以及基于梯度下降的反向传播算法,并配合关键公式与直观解释。

  • 实践篇:基于 NumPy 完成参数初始化、前向传播、损失计算、反向传播与参数更新,并在 MNIST 数据集上训练和测试模型(不依赖 TensorFlow / PyTorch 等高阶框架)。

读完本文,你不仅能运行一个可用的神经网络,还能清晰理解它为什么有效

理论篇:三层神经网络的工作原理

本文会实现一个最简单的网络,只有三层:输入层 → 1 个隐藏层 → 输出层。虽然简单,但是已经涵盖了神经网络的核心和主要流程。

网络结构(输入层、隐藏层、输出层)

一张 28×28 的灰度图在展平后就是 784 维向量,作为输入层。隐藏层接收来自输入层的==加权和(再加偏置)==,经过非线性激活输出。输出层再对隐藏层输出进行线性变换与(任务相关的)激活,得到最终预测(多分类用 softmax 概率)。

  • 输入层:仅承载特征,不做计算。
  • 隐藏层:对输入做线性变换 + 偏置,再过激活函数(如 sigmoid),学习非线性特征。
  • 输出层:对隐藏层输出做线性变换 + 偏置,再过适合任务的激活(多分类常用 softmax)。
  • 偏置项:每个非输入层的神经元都会有一个可学习偏置,类似线性回归中的截距。

全连接(dense)意味着上一层的每个神经元都与下一层每个神经元相连,每条连接都有一个可学习权重

记号与代数

设:

  • 输入维度 $n_x$,隐藏层神经元数 $n_h$,输出类别数 $n_y$。
  • 输入→隐藏权重 $W^{[1]} \in \mathbb{R}^{n_x \times n_h}$,偏置 $b^{[1]} \in \mathbb{R}^{n_h}$。
  • 隐藏→输出权重 $W^{[2]} \in \mathbb{R}^{n_h \times n_y}$,偏置 $b^{[2]} \in \mathbb{R}^{n_y}$。

对单样本 $\mathbf{x} \in \mathbb{R}^{n_x}$: $\mathbf{z}^{[1]} = W^{[1]} \mathbf{x} + \mathbf{b}^{[1]}, \quad \mathbf{a}^{[1]} = \sigma(\mathbf{z}^{[1]}),$ $\mathbf{z}^{[2]} = W^{[2]} \mathbf{a}^{[1]} + \mathbf{b}^{[2]}, \quad \mathbf{a}^{[2]} = f(\mathbf{z}^{[2]}),$ 其中 $\sigma$ 为隐藏层激活函数(本文使用 sigmoid),$f$ 为输出层激活函数(本文使用 softmax)。

前向传播(Forward Propagation)

前向传播就是“线性变换 + 非线性激活”的层层堆叠:

  1. 输入→隐藏:$\mathbf{z}{[1]} = W{[1]} \mathbf{x} + \mathbf{b}{[1]}, \quad \mathbf{a}{[1]} = \sigma(\mathbf{z}_{[1]}).$
  2. 隐藏→输出: $\mathbf{z}{[2]} = W{[2]} \mathbf{a}{[1]} + \mathbf{b}{[2]}, \quad \mathbf{a}{[2]} = \text{softmax}(\mathbf{z}{[2]}).$

无非是矩阵乘法与逐元素非线性。==如果没有非线性激活,堆多少层还是线性模型,表达力不会增强==。

激活函数:Sigmoid 与 Softmax

  • Sigmoid(逻辑函数): $\sigma(x) = \frac{1}{1+e^{-x}},\qquad \sigma’(x)=\sigma(x)\big(1-\sigma(x)\big).$

输出落在 $(0,1)$,历史上常用于隐藏层(现代更常用 ReLU,但本文用 sigmoid 便于推导与实现)。

  • Softmax(多分类输出概率):\ 对 $\mathbf{z}\in\mathbb{R}^{n_y}$: $\text{softmax}(\mathbf{z})i=\frac{e^{z_i}}{\sum{k=1}^{n_y}e^{z_k}},$ 得到各类的概率分布(和为 1)。实现时常减去行最大值以稳定数值。

损失函数:交叉熵(Cross-Entropy)

多分类常用交叉熵损失。若真实标签 one-hot 为 $\mathbf{y}$,预测概率为 $\mathbf{p}$(softmax 输出): $L = -\sum_{i=1}^{n_y} y_i \log p_i.$ 若预测把正确类的概率压高,损失就小;反之损失大。训练时通常取样本平均。

反向传播(Backpropagation)与梯度下降(Gradient Descent)

==训练的核心是用反向传播计算梯度==,再用梯度下降更新参数,使损失下降。

  • 输出层误差(softmax+交叉熵的便利结果): $\frac{\partial L}{\partial \mathbf{z}^{[2]}} = \mathbf{a}^{[2]} - \mathbf{y}.$

  • 第二层梯度: $\frac{\partial L}{\partial W^{[2]}} = \mathbf{a}^{[1]}(\mathbf{a}^{[2]}-\mathbf{y})^\top,\quad \frac{\partial L}{\partial \mathbf{b}^{[2]}} = \mathbf{a}^{[2]}-\mathbf{y}.$

  • 隐藏层误差: $\delta^{[1]}=\frac{\partial L}{\partial \mathbf{z}^{[1]}} = \left(W^{[2]\top}(\mathbf{a}^{[2]}-\mathbf{y})\right) \circ \sigma’(\mathbf{z}^{[1]}),$ 其中 $\circ$ 为逐元素乘。

  • 第一层梯度: $\frac{\partial L}{\partial W^{[1]}}=\mathbf{x}(\delta^{[1]})^\top,\quad \frac{\partial L}{\partial \mathbf{b}^{[1]}}=\delta^{[1]}.$

  • 梯度下降更新(学习率 $\alpha$): $W := W - \alpha \frac{\partial L}{\partial W},\quad b := b - \alpha \frac{\partial L}{\partial b}.$

权重初始化要随机且小:若全零初始化,隐藏单元梯度完全相同,永远学不出差异化特征(对称性无法打破)。偏置通常可初始化为 0。


实践篇:用 NumPy 从零实现并在 MNIST 上训练

我们按以下步骤实现:

  1. 加载数据(MNIST,70k 张 28×28 灰度图;60k 训练、10k 测试)。

  2. 初始化参数(随机小权重、零偏置)。

  3. 前向传播(含 sigmoid/softmax)。

  4. 损失计算(交叉熵)。

  5. 反向传播(矢量化计算梯度)。

  6. 参数更新(批量梯度下降)。

  7. 训练循环(多 epoch)。

  8. 测试集评估

说明:数据加载我们用 scikit-learnfetch_openml 便捷获取 MNIST;模型计算全用 NumPy。若无 sklearn,可改为手动下载/解压 MNIST。

1) 加载 MNIST

kaggle 下载数据集:https://www.kaggle.com/datasets/aadeshkoirala/mnist-784/code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import pandas as pd

dataset = pd.read_csv("mnist_784.csv")
X = dataset.iloc[:, :-1].values
y = dataset.iloc[:, -1].values
print(X.shape, y.shape) # (70000, 784) (70000,)

# 划分训练/测试
X_train, X_test = X[:60000], X[60000:]
y_train, y_test = y[:60000], y[60000:]

# 像素归一化到 [0,1]
X_train = X_train / 255.0
X_test = X_test / 255.0

# one-hot 标签
num_classes = 10
y_train_onehot = np.eye(num_classes)[y_train] # (60000, 10)
y_test_onehot = np.eye(num_classes)[y_test] # (10000, 10)

2) 初始化权重与偏置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 维度设定
n_x = 784 # 输入
n_h = 64 # 隐藏层单元数(可调)
n_y = 10 # 输出类别数

# 参数初始化
np.random.seed(42) # 复现实验
W1 = np.random.randn(n_x, n_h) * 0.01 # (784, 64)
b1 = np.zeros((n_h,)) # (64,)
W2 = np.random.randn(n_h, n_y) * 0.01 # (64, 10)
b2 = np.zeros((n_y,)) # (10,)

print("W1:", W1.shape, "b1:", b1.shape)
print("W2:", W2.shape, "b2:", b2.shape)

3) 前向传播实现

1
2
3
4
5
6
7
8
def sigmoid(z):
return 1 / (1 + np.exp(-z))

def softmax(z):
# z: (m, n_y)
z_shift = z - np.max(z, axis=1, keepdims=True) # 数值稳定
exp_z = np.exp(z_shift)
return exp_z / np.sum(exp_z, axis=1, keepdims=True)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 训练集上的一次前向传播
Z1 = np.dot(X_train, W1) + b1 # (60000, 64)
A1 = sigmoid(Z1) # (60000, 64)
Z2 = np.dot(A1, W2) + b2 # (60000, 10)
A2 = softmax(Z2) # (60000, 10)

# 交叉熵损失
m = X_train.shape[0]
log_probs = -np.log(A2 + 1e-8) * y_train_onehot
loss = np.sum(log_probs) / m
print("Initial loss:", loss)

# 初始准确率(几乎随机)
pred = np.argmax(A2, axis=1)
acc = np.mean(pred == y_train)
print("Initial training accuracy:", acc)

初始损失应接近 $ln⁡(10)≈2.302\ln(10)\approx 2.302$,准确率约 10%。

4) 反向传播实现(矢量化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 反向传播
m = X_train.shape[0]

# 输出层误差
dZ2 = A2 - y_train_onehot # (60000, 10)
# 第二层梯度
dW2 = np.dot(A1.T, dZ2) / m # (64, 10)
db2 = np.sum(dZ2, axis=0) / m # (10,)
# 传播回隐藏层
dA1 = np.dot(dZ2, W2.T) # (60000, 64)
dZ1 = dA1 * (A1 * (1 - A1)) # sigmoid 导数
# 第一层梯度
dW1 = np.dot(X_train.T, dZ1) / m # (784, 64)
db1 = np.sum(dZ1, axis=0) / m # (64,)

print(dW2.shape, db2.shape, dW1.shape, db1.shape)

5) 参数更新(梯度下降)

1
2
3
4
5
6
learning_rate = 0.1

W2 -= learning_rate * dW2
b2 -= learning_rate * db2
W1 -= learning_rate * dW1
b1 -= learning_rate * db1

6) 训练循环(多轮迭代)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 训练
epochs = 10
learning_rate = 0.1
m = X_train.shape[0]

for epoch in range(1, epochs + 1):
# 前向
Z1 = np.dot(X_train, W1) + b1
A1 = sigmoid(Z1)
Z2 = np.dot(A1, W2) + b2
A2 = softmax(Z2)
# 损失
log_probs = -np.log(A2 + 1e-8) * y_train_onehot
loss = np.sum(log_probs) / m
# 反向
dZ2 = A2 - y_train_onehot
dW2 = np.dot(A1.T, dZ2) / m
db2 = np.sum(dZ2, axis=0) / m
dA1 = np.dot(dZ2, W2.T)
dZ1 = dA1 * (A1 * (1 - A1))
dW1 = np.dot(X_train.T, dZ1) / m
db1 = np.sum(dZ1, axis=0) / m
# 更新
W2 -= learning_rate * dW2
b2 -= learning_rate * db2
W1 -= learning_rate * dW1
b1 -= learning_rate * db1
# 监控训练准确率
train_preds = np.argmax(A2, axis=1)
train_acc = np.mean(train_preds == y_train)
print(f"Epoch {epoch}: loss = {loss:.4f}, training accuracy = {train_acc:.4f}")

正常情况下,损失会逐步下降、准确率提升。以 64 隐藏单元、10 个 epoch 为例,训练准确率大致能到 0.90 左右(因初始化与学习率不同会有差异)。

7) 测试集评估

1
2
3
4
5
6
7
8
9
# 测试前向
Z1_test = np.dot(X_test, W1) + b1
A1_test = sigmoid(Z1_test)
Z2_test = np.dot(A1_test, W2) + b2
A2_test = softmax(Z2_test)

test_preds = np.argmax(A2_test, axis=1)
test_acc = np.mean(test_preds == y_test)
print("Test accuracy:", test_acc)

你应能看到约 88%–92% 的测试准确率(取决于超参数与训练轮数)。这对一个仅一层隐藏层的纯 NumPy 实现来说已经相当不错。


小结与拓展

本文用 NumPy 从零实现了一个三层全连接神经网络,并在 MNIST 上完成训练与评估。我们:

  • 将网络拆解为“线性($Wx+bW\mathbf{x}+b$)+ 非线性(激活)”的层级组合;

  • 在隐藏层用 sigmoid,在输出层用 softmax

  • 交叉熵 衡量分类误差;

  • 推导并实现了反向传播的向量化梯度;

  • 梯度下降迭代更新参数,并在测试集上做了评估。

源码

https://github.com/janice143/machine-learning-a-z