你是否觉得神经网络很神秘,想抛开框架从零理解它的运作?其实,它的核心不过是一系列矩阵运算和非线性变换的叠加。本文将用大约100行 Python 代码,带你手动搭建一个能实际运行的两层神经网络,用于解决一个简单的非线性二分类问题。
目标与数据准备
我们的目标是构建一个逻辑清晰、代码量小的可运行网络。为此,我们设计一个简单的Demo:
- 使用
numpy 生成二维特征数据。
- 人为设定一个非线性的分类规则(让线性模型难以处理)。
- 构建一个两层的全连接网络进行分类。
首先,我们生成模拟数据,这比下载大型数据集更直观、运行更快。
import numpy as np
# 设置随机种子,保证结果可复现
np.random.seed(42)
# 生成200个二维数据点
num_samples = 200
X = np.random.randn(num_samples, 2) # 形状: (200, 2)
# 人为创建一个非线性分类规则:x和y同号时为1,异号时为0
y = ((X[:, 0] * X[:, 1]) > 0).astype(int) # 形状: (200,)
y = y.reshape(-1, 1) # 变为列向量 (200, 1)
简单来说:
X:输入特征,200行2列,每一行是一个样本点 (x1, x2)。
y:对应标签,取值为0或1,告诉模型每个样本属于哪一类。
网络结构拆解
在写代码前,先用通俗语言描述网络的工作流程:
- 第一层(全连接):将2维输入映射到一个16维的隐藏层。
- 激活函数(ReLU):对隐藏层输出进行非线性变换,将负数置零。
- 第二层(全连接):将16维隐藏层输出映射到1维。
- 激活函数(Sigmoid):将最终输出压缩到(0, 1)区间,作为预测概率。
- 损失计算:使用交叉熵衡量预测与真实标签的差距。
- 反向传播:通过链式法则计算梯度,并更新网络权重。
对应的数学公式为:
- 第一层:
z1 = X @ W1 + b1, a1 = relu(z1)
- 第二层:
z2 = a1 @ W2 + b2, y_pred = sigmoid(z2)
初始化参数与定义激活函数
接下来,我们初始化网络参数并定义两个关键的激活函数。
# 定义网络结构维度
input_dim = 2
hidden_dim = 16
output_dim = 1 # 二分类,输出一个概率值
# 初始化权重和偏置(使用小随机数)
W1 = 0.01 * np.random.randn(input_dim, hidden_dim)
b1 = np.zeros((1, hidden_dim))
W2 = 0.01 * np.random.randn(hidden_dim, output_dim)
b2 = np.zeros((1, output_dim))
# 定义ReLU激活函数
def relu(x):
return np.maximum(0, x)
# 定义Sigmoid激活函数
def sigmoid(x):
return 1 / (1 + np.exp(-x))
这里只需建立初步印象:
W1:一个2x16的矩阵,负责将二维输入投射到16维空间。
b1:偏置项,维度为1x16。
relu:将所有负输入值变为0,正数保持不变。
sigmoid:将任意实数映射到(0, 1)之间,完美适配概率解释。
实现前向传播与损失函数
前向传播指的是数据从输入到输出通过网络的全过程。
def forward(X):
"""前向传播,返回各层中间结果,便于后续反向传播。"""
z1 = X @ W1 + b1 # (N, 2) @ (2, 16) -> (N, 16)
a1 = relu(z1) # (N, 16)
z2 = a1 @ W2 + b2 # (N, 16) @ (16, 1) -> (N, 1)
y_pred = sigmoid(z2) # (N, 1)
return z1, a1, z2, y_pred
def binary_cross_entropy(y_true, y_pred, eps=1e-8):
"""计算二分类交叉熵损失。"""
y_pred = np.clip(y_pred, eps, 1 - eps) # 防止log(0)计算错误
loss = - (y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
return np.mean(loss)
forward 函数返回每一层的中间结果 (z1, a1, z2),这在反向传播计算梯度时会用到,避免了重复计算。
核心:反向传播与参数更新
神经网络的大部分“神秘感”源于反向传播。但对于我们这个小网络,它本质上是应用链式法则进行的一系列矩阵求导运算。
def train_step(X, y, lr=0.1):
"""
单步训练:执行一次前向传播、反向传播和参数更新。
此处使用批量梯度下降。
"""
global W1, b1, W2, b2
num_samples = X.shape[0]
# 1. 前向传播
z1, a1, z2, y_pred = forward(X)
loss = binary_cross_entropy(y, y_pred)
# 2. 反向传播
# 输出层梯度:对于二分类交叉熵+sigmoid,有 dz2 = y_pred - y
dz2 = y_pred - y # (N, 1)
dW2 = a1.T @ dz2 / num_samples # (16, N) @ (N, 1) -> (16, 1)
db2 = np.sum(dz2, axis=0, keepdims=True) / num_samples # (1, 1)
# 将梯度传播回隐藏层
da1 = dz2 @ W2.T # (N, 1) @ (1, 16) -> (N, 16)
dz1 = da1.copy()
dz1[z1 <= 0] = 0 # ReLU的梯度:输入<=0的位置,梯度为0
dW1 = X.T @ dz1 / num_samples # (2, N) @ (N, 16) -> (2, 16)
db1 = np.sum(dz1, axis=0, keepdims=True) / num_samples # (1, 16)
# 3. 参数更新(梯度下降)
W1 -= lr * dW1
b1 -= lr * db1
W2 -= lr * dW2
b2 -= lr * db2
return loss
你可以把这个过程当作一个模板来记忆:
- 输出层梯度:
dz2 = y_pred - y 这个公式在二分类(交叉熵损失 + Sigmoid激活)中非常经典。
- 权重梯度:通常是
(上一层输出).T @ (当前层梯度)。
- 偏置梯度:对当前层梯度沿批次(batch)维度求和即可。
- ReLU梯度:输入为正时梯度为1,输入非正时梯度为0。代码通过
z1 <= 0 来判断。
执行训练循环
有了 train_step 函数,训练循环就变得非常简洁明了。
# 开始训练
epochs = 1000
for epoch in range(1, epochs + 1):
loss = train_step(X, y, lr=0.5) # 使用稍大的学习率加速收敛
if epoch % 100 == 0:
# 计算并打印当前准确率
_, _, _, y_pred = forward(X)
y_label = (y_pred > 0.5).astype(int)
acc = (y_label == y).mean()
print(f"epoch {epoch}, loss = {loss:.4f}, acc = {acc:.3f}")
在这个循环中,每次调用 train_step 就完成了一次全量数据的梯度下降。每隔100轮打印一次损失和准确率,你可以直观地看到模型性能的提升过程。
使用模型进行预测
训练完成后,我们提供一个简单的预测函数。
def predict(X_new):
"""对新数据进行预测。"""
_, _, _, y_pred = forward(X_new)
return (y_pred > 0.5).astype(int)
现在,你可以随意创建一些点来测试模型的分类效果:
test_points = np.array([
[1.0, 1.0],
[1.0, -1.0],
[-0.5, -0.8],
[-0.5, 0.8]
])
print(predict(test_points))
总结与扩展思考
将上面的代码片段组合起来,一个完整的、可运行的简易神经网络脚本就在100行左右。通过这个实践,希望你脑中能建立以下几个关键认知:
- 神经网络的基础是矩阵乘法和非线性激活函数的堆叠。
- 反向传播是误差从输出层向输入层反向传递,并应用链式法则计算梯度的过程。
- 对于二分类问题,“Sigmoid + 交叉熵”是输出层的标准配置之一。
- 一旦你能用
numpy 手动实现,再看 PyTorch、Keras 等深度学习框架的API,就会觉得豁然开朗。
为了加深理解,你可以尝试以下小实验,这将是未来调参的直觉来源:
- 将
hidden_dim(隐藏层维度)改大或改小,观察模型拟合能力的变化。
- 将
relu 激活函数换成 np.tanh,感受不同非线性对训练效果的影响。
- 调整学习率
lr(例如改为0.05或1.0),观察收敛速度是变快、变慢还是训练不稳定。
理解深度学习的基础原理,是从工具使用者迈向真正理解者的关键一步。欢迎在云栈社区分享你的实验心得或提出更深层次的技术问题。