从零开始构建神经网络:仅用 NumPy 实现深度学习核心算法

在深度学习领域,使用 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 调用之下,数据是如何流动,权重是如何更新的。这种底层的理解,将使你成为一名更具竞争力的算法工程师。

希望这篇教程对你有所帮助。你可以尝试修改代码,比如增加隐藏层的神经元数量,或者改变学习率,观察它们如何影响模型的学习速度和准确率。这就是实验的乐趣所在!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/36135.html
点赞
0.00 平均评分 (0% 分数) - 0