在深度学习领域,使用 TensorFlow 或 PyTorch 等高级框架固然方便,但作为有追求的开发者,我们必须理解那些隐藏在抽象层之下的核心原理。这就好比虽然我们都开自动挡汽车,但了解发动机的工作原理能让你成为更出色的机械师。
在这篇文章中,我们将完全抛弃现成的深度学习框架,仅使用 Python 的基础数值计算库 NumPy,从零开始手写一个神经网络。我们将通过构建一个能够识别字母 A、B 和 C 的分类器,来深入探索神经网络的前向传播、反向传播、权重更新等核心机制。准备好了吗?让我们开始这段深入机器学习腹地的旅程吧。
目录
神经网络基础架构
在编写第一行代码之前,我们需要明确我们要构建的目标。神经网络本质上是一种模仿生物神经系统结构的数学模型。它由一系列相互连接的节点(神经元)组成,这些节点按层排列。
对于我们即将构建的字母识别系统,网络架构将包含以下关键部分:
- 输入层:这是网络的“眼睛”。因为我们要处理 5×6 像素的图像,展平后共有 30 个像素点,所以输入层将有 30 个节点,负责接收原始数据。
- 隐藏层:这是网络的“大脑皮层”,负责提取特征。我们将设置一个包含 5 个神经元的隐藏层,用于处理输入信号并应用非线性变换。
- 输出层:这是网络的“决策者”。由于我们要分类 A、B、C 三个字母,输出层将包含 3 个神经元,分别代表这三个类别的概率。
步骤 1:构建与准备数据集
实际项目中的数据往往是杂乱无章的,但在我们的实验环境中,为了聚焦于算法本身,我们首先需要构建一个清晰的数据集。我们将使用二进制矩阵(由 0 和 1 组成)来可视化字母。
数据定义与可视化
让我们先定义字母 A、B、C 的矩阵表示。为了方便处理,我们将 5×6 的图像展平为长度为 30 的一维数组。同时,我们需要定义对应的标签,这里使用 One-Hot 编码(独热编码),这是一种非常适合处理分类问题的编码方式。
import numpy as np
import matplotlib.pyplot as plt
# 定义字母 A 的 5x6 像素网格(展平为 30 个元素)
# 1 代表有笔画的像素,0 代表背景
a = [0, 0, 1, 1, 0, 0,
0, 1, 0, 0, 1, 0,
1, 1, 1, 1, 1, 1,
1, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 1]
# 定义字母 B
b = [0, 1, 1, 1, 1, 0,
0, 1, 0, 0, 1, 0,
0, 1, 1, 1, 1, 0,
0, 1, 0, 0, 1, 0,
0, 1, 1, 1, 1, 0]
# 定义字母 C
c = [0, 1, 1, 1, 1, 0,
0, 1, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0,
0, 1, 1, 1, 1, 0]
# 定义标签:使用 One-Hot 编码
# [1, 0, 0] 代表 A
# [0, 1, 0] 代表 B
# [0, 0, 1] 代表 C
y_labels = [[1, 0, 0],
[0, 1, 0],
[0, 0, 1]]
# 为了更好地理解数据,让我们可视化字母 A
# 将一维数组重塑为 5x6 的矩阵并显示
plt.imshow(np.array(a).reshape(5, 6), cmap=‘gray‘)
plt.title("Letter A Visualization")
plt.show()
代码解析:
在上面的代码中,我们不仅定义了数据,还做了一个非常重要的一步——可视化。在处理图像数据时,一定要先“看”一眼数据。使用 plt.imshow 可以确认我们的矩阵形状是否符合预期,避免因为维度错误导致后续训练出 bug。
数据格式转换
虽然列表在 Python 中很方便,但 NumPy 数组在数值计算上效率更高。我们需要将数据转换为 NumPy 数组,并调整其维度以匹配神经网络的输入要求(样本数 x 特征数)。
# 将数据转换为 NumPy 数组并重塑为 (1, 30) 的形状
# 这里的 1 代表样本数量(我们是一个一个处理的),30 是特征数量
x = [np.array(a).reshape(1, 30),
np.array(b).reshape(1, 30),
np.array(c).reshape(1, 30)]
y = np.array(y_labels)
# 打印检查数据形状
print(f"输入数据 A 的形状: {x[0].shape}")
print(f"标签数据的形状: {y.shape}")
print("
标签数据:
", y)
步骤 2:核心组件实现
神经网络并非一个黑盒,它是由几个数学函数组合而成的。在这一步,我们将把这些数学公式转化为可执行的 Python 代码。
1. 激活函数
线性方程(直线)无法解决复杂问题。我们需要引入非线性,这就需要激活函数。我们将使用 Sigmoid 函数,它能够将任何数值压缩到 0 和 1 之间,非常适合表示概率。
def sigmoid(x):
"""
Sigmoid 激活函数
公式: 1 / (1 + e^(-x))
作用:将输入映射到 (0, 1) 区间,引入非线性因素
"""
return (1 / (1 + np.exp(-x)))
为什么选择 Sigmoid? 虽然现代深度学习中 ReLU 更为流行,但 Sigmoid 的输出特性非常适合我们在输出层生成概率分布。
2. 权重初始化
网络需要从某种状态开始学习。如果权重初始值都相同,神经元就无法学习到不同的特征。我们将使用正态分布来随机初始化权重。
def generate_wt(x, y):
"""
生成随机权重矩阵
参数:
x: 输入维度(行数)
y: 输出维度(列数)
返回:
符合正态分布的随机矩阵
"""
# 创建一个包含 x*y 个随机数的列表
li = [np.random.randn() for _ in range(x * y)]
return np.array(li).reshape(x, y)
步骤 3:前向传播
前向传播就是数据从输入层流经隐藏层,最终到达输出层的过程。在这个过程中,数据被加权求和,并通过激活函数变换。
def f_forward(x, w1, w2):
"""
前向传播函数
计算神经网络在给定权重下的预测输出
"""
# 第一层(隐藏层)
# z1 是输入 x 与权重 w1 的点积(矩阵乘法)
z1 = x.dot(w1)
# a1 是经过 Sigmoid 激活后的隐藏层输出
a1 = sigmoid(z1)
# 第二层(输出层)
# z2 是隐藏层输出 a1 与权重 w2 的点积
z2 = a1.dot(w2)
# a2 是最终的预测结果(经过激活)
a2 = sigmoid(z2)
return a2
步骤 4:损失函数与反向传播
这是神经网络“学习”的关键部分。我们需要一种方法来衡量预测结果与真实结果的差距(损失),并计算出如何调整权重以减小这种差距(反向传播)。
损失函数
我们将使用均方误差来量化预测的准确度。
def loss(out, Y):
"""
计算损失值(均方误差 Mean Squared Error)
参数:
out: 神经网络的预测输出
Y: 真实标签
"""
# 计算预测值与真实值差的平方
s = (np.square(out - Y))
# 求平均,得到标量损失值
s = np.sum(s) / len(y)
return s
反向传播
这是初学者最容易感到困惑的部分。简单来说,我们需要计算损失函数相对于每个权重的梯度(导数),告诉权重应该增加还是减少,以及变化多少。我们使用链式法则来计算这些梯度。
def back_prop(x, y, w1, w2, alpha):
"""
反向传播与权重更新
参数:
x: 输入数据
y: 真实标签
w1, w2: 当前权重
alpha: 学习率(控制权重更新的步长)
返回:
更新后的 w1, w2
"""
# --- 1. 前向传播(计算当前预测) ---
z1 = x.dot(w1)
a1 = sigmoid(z1)
z2 = a1.dot(w2)
a2 = sigmoid(z2)
# --- 2. 计算输出层的误差 ---
# d2 是预测值与真实值的误差
d2 = (a2 - y)
# --- 3. 计算隐藏层的误差 ---
# 利用链式法则,将输出层的误差反向传递给隐藏层
# np.multiply 是逐元素相乘
d1 = np.multiply((w2.dot((d2.transpose()))).transpose(),
np.multiply(a1, 1 - a1))
# --- 4. 梯度计算与权重更新 ---
# 更新第一层权重 w1
# 梯度 = 输入转置 * 隐藏层误差
w1_gradient = x.transpose().dot(d1)
w1 = w1 - (alpha * w1_gradient)
# 更新第二层权重 w2
# 梯度 = 隐藏层输出转置 * 输出层误差
w2_gradient = a1.transpose().dot(d2)
w2 = w2 - (alpha * w2_gradient)
return w1, w2
深度解析:注意 np.multiply(a1, 1 - a1) 这部分,这是 Sigmoid 函数的导数。如果不乘以这一项(激活函数的斜率),梯度就不会正确地衰减,网络将无法收敛。
步骤 5:训练模型
现在我们已经有了所有的积木:数据、前向传播、损失计算和反向传播。接下来就是把它们串联起来,进行迭代训练。
def train(x, Y, w1, w2, alpha, epoch):
"""
训练循环
参数:
epoch: 训练轮数
alpha: 学习率
"""
print("开始训练...
")
for i in range(epoch):
l = [] # 用于记录每轮的损失
for j in range(len(x)):
# 对每个样本进行前向传播
out = f_forward(x[j], w1, w2)
# 计算并记录损失
l.append(loss(out, Y[j]))
# 执行反向传播并更新权重
w1, w2 = back_prop(x[j], Y[j], w1, w2, alpha)
# 每 1000 轮打印一次平均损失
if i % 1000 == 0:
print(f"Epoch {i}, Loss: {sum(l)/len(x)}")
print("
训练完成!")
return w1, w2
步骤 6:完整运行与预测
让我们初始化权重并开始训练。我们将设置较高的学习率和较多的轮数,以确保这个小网络能够完全记住训练数据。
import random
# 初始化权重
# w1: 输入层(30) -> 隐藏层(5)
# w2: 隐藏层(5) -> 输出层(3)
w1 = generate_wt(30, 5)
w2 = generate_wt(5, 3)
print("初始权重 w1 形状:", w1.shape)
print("初始权重 w2 形状:", w2.shape)
# 设置超参数
# alpha: 学习率,决定了每次梯度下降的步长
# epoch: 训练遍历数据集的次数
alpha = 0.01
epoch = 15000
# 开始训练
w1, w2 = train(x, y, w1, w2, alpha, epoch)
# --- 测试阶段 ---
print("
--- 预测测试 ---")
def predict(input_letter, w1, w2, name):
"""
预测函数
"""
# 前向传播
prediction = f_forward(input_letter, w1, w2)
# 找出概率最大的那个神经元
max_index = np.argmax(prediction)
# 映射回字母
letters = [‘A‘, ‘B‘, ‘C‘]
predicted_letter = letters[max_index]
print(f"输入字母: {name}")
print(f"网络输出概率: {prediction}")
print(f"预测结果: {predicted_letter}")
print("-" * 20)
# 分别测试 A, B, C
predict(x[0], w1, w2, "A")
predict(x[1], w1, w2, "B")
predict(x[2], w1, w2, "C")
常见问题与优化建议
在我们实现的过程中,你可能会遇到一些挑战。以下是一些实战中的见解和解决方案:
- 梯度消失:如果你发现 Loss 下降非常慢,甚至在几乎不动,可能是学习率太小,或者你使用的 Sigmoid 函数导致了梯度饱和。如果网络很深,Sigmoid 容易导致梯度消失,但在我们这种单隐层网络中通常没问题。
- 过拟合:在这个例子中,我们的参数量远大于数据量,模型会“死记硬背”训练数据。在实际应用中,你需要引入验证集来监控模型在未见数据上的表现,或者使用 Dropout 技术随机丢弃部分神经元来防止过拟合。
- 权重初始化的重要性:如果我们把权重全部初始化为 0,那么网络就无法学习,因为所有神经元的更新方向将完全一致。这就是为什么我们在
generate_wt中使用随机数的原因。
- 性能优化:在实际的大规模应用中,我们会使用批量训练,即一次性向网络输入多个样本(例如 32 或 64 个),这样可以充分利用 CPU/GPU 的并行计算能力,大大加快训练速度。本教程为了代码清晰,采用了随机梯度下降(SGD)的方式,即一次只输入一个样本。
总结
通过这篇文章,我们不仅从零实现了一个神经网络,更重要的是,我们透视了那些高级框架(如 TensorFlow 或 PyTorch)背后的魔法。我们看到,所谓的“智能”,归根结底就是矩阵乘法、微积分导数和迭代优化的结合。
当你下次调用 model.fit() 时,你会更加自信,因为你清楚地知道在那些抽象的 API 调用之下,数据是如何流动,权重是如何更新的。这种底层的理解,将使你成为一名更具竞争力的算法工程师。
希望这篇教程对你有所帮助。你可以尝试修改代码,比如增加隐藏层的神经元数量,或者改变学习率,观察它们如何影响模型的学习速度和准确率。这就是实验的乐趣所在!