不用框架,仅用NumPy在Python中手动实现简单神经网络

小林子
小林子
发布时间:2026-10-27 | 6人浏览过
原创
实现神经网络的核心并不依赖复杂的框架抽象,而是矩阵乘法与微分链式法则的结合。本文仅使用 Python 标准库与 NumPy,从零推导并搭建一个双层全连接神经网络,用于解决经典的非线性可分 XOR 逻辑问题。通过手动编写前向计算、损失评估、反向求导与梯度下降更新,读者可以彻底理清数据与梯度在网络层之间的流动全貌。

一、网络结构设计与张量维度约定

为了验证神经网络对非线性特征的学习能力,我们选取经典的异或(XOR)分类问题作为目标。单层感知机因为无法生成非线性超平面而无法解决 XOR 问题,但只要引入一个具备非线性激活函数的隐藏层,网络就能轻松构建出弯曲的决策边界。

我们设计的网络结构为典型的两层多层感知机(MLP):

  • 输入层(Input Layer):包含 2 个输入特征(表示输入的两个布尔或二值变量),批次样本大小记为 $N$,输入矩阵 $X$ 的维度为 $(N, 2)$。
  • 隐藏层(Hidden Layer):包含 4 个神经元。权重矩阵 $W_1$ 维度为 $(2, 4)$,偏置向量 $b_1$ 维度为 $(1, 4)$,激活函数采用 Sigmoid。输出激活矩阵 $A_1$ 维度为 $(N, 4)$。
  • 输出层(Output Layer):包含 1 个输出神经元。权重矩阵 $W_2$ 维度为 $(4, 1)$,偏置向量 $b_2$ 维度为 $(1, 1)$,激活函数采用 Sigmoid。预测输出 $\hat{Y}$ 维度为 $(N, 1)$。
不用框架,仅用NumPy在Python中手动实现简单神经网络
图 1:双层前馈神经网络的拓扑连接与各层权重、偏置的矩阵维度设计
维度匹配准则:在矩阵乘法 Z = np.dot(X, W) + b 中,输入矩阵的列数必须严格等于权重矩阵的行数。广播机制(Broadcasting)会自动将形状为 (1, K) 的偏置向量按行复制扩展到所有 N 个样本上。

二、核心数学构件:激活函数与损失计算

神经网络通过非线性激活函数打破矩阵乘法的线性叠加。如果所有层都不使用激活函数,无论堆叠多少层,多层线性变换最终等价于单个矩阵相乘,网络将完全丧失拟合非线性模式的能力。

1. Sigmoid 激活函数及其导数

Sigmoid 函数将任意实数映射到 $(0, 1)$ 区间,非常适合作为概率输出或门控信号。其数学定义为:

σ(z) = 1 / (1 + exp(-z))

在进行反向传播求导时,Sigmoid 具备一个非常优雅的代数特性——其导数可以直接用激活值自身来表达:

dσ(z) / dz = σ(z) * (1 - σ(z))

工程实现时需要注意数值稳定性。当 $z$ 是一个极大的负数时,np.exp(-z) 可能发生溢出(Overflow)。我们可以通过 np.clip 将输入限制在安全范围 $[-500, 500]$ 内。

2. 损失函数定义(均方误差 MSE)

针对预测输出 $\hat{Y}$ 和真实标签 $Y$,采用带系数 $1/2$ 的均方误差作为目标损失函数:

Loss = (1 / (2 * N)) * Σ (Ŷ - Y)²

常数 $1/2$ 的引入是为了在对 $\hat{Y}$ 求导时抵消平方项求导产生的系数 $2$,从而让梯度公式更加清爽:

dLoss / dŶ = (1 / N) * (Ŷ - Y)

三、前向传播(Forward Propagation)计算流

前向传播负责将输入数据逐层向前推进,依次完成线性映射与非线性激活,最终输出模型预测并评估当前损失。整个过程分为以下两阶段:

  1. 隐藏层计算:计算隐藏层未激活值 $Z_1 = X W_1 + b_1$,随后通过激活函数得到 $A_1 = \sigma(Z_1)$。
  2. 输出层计算:以 $A_1$ 作为输入,计算输出层未激活值 $Z_2 = A_1 W_2 + b_2$,随后通过激活函数得到最终预测 $\hat{Y} = A_2 = \sigma(Z_2)$。
不用框架,仅用NumPy在Python中手动实现简单神经网络
图 2:前向传播中的矩阵变换与非线性激活数据流动链路

四、反向传播(Backpropagation)链式求导详解

反向传播的本质是高等数学中的多元复合函数求导链式法则(Chain Rule)。它的核心目标是:计算出损失函数 $L$ 关于网络中每一个权重和偏置的偏导数(梯度),以便后续使用梯度下降法更新参数。

1. 输出层梯度推导

我们首先求损失 $L$ 关于输出层未激活值 $Z_2$ 的导数,记为误差项 $\delta_2$:

δ2 = ∂L / ∂Z2 = (∂L / ∂A2) ⊙ (∂A2 / ∂Z2)
   = [(1 / N) * (A2 - Y)] ⊙ [A2 ⊙ (1 - A2)]

其中 $\odot$ 表示逐元素相乘(Hadamard积)。有了 $\delta_2$ 后,由 $Z_2 = A_1 W_2 + b_2$,可分别得到输出层权重与偏置的梯度:

∂L / ∂W2 = A1ᵀ · δ2        # 维度: (4, N) · (N, 1) = (4, 1)
∂L / ∂b2 = Σ_rows (δ2)     # 维度: (1, 1),沿样本轴对梯度进行求和

2. 隐藏层梯度推导

误差继续向后传导至隐藏层。首先计算损失对隐藏层激活值 $A_1$ 的导数:

∂L / ∂A1 = δ2 · W2ᵀ        # 维度: (N, 1) · (1, 4) = (N, 4)

然后穿过隐藏层的 Sigmoid 激活函数,得到隐藏层未激活值的误差项 $\delta_1$:

δ1 = ∂L / ∂Z1 = (∂L / ∂A1) ⊙ σ'(Z1) = (δ2 · W2ᵀ) ⊙ [A1 ⊙ (1 - A1)]

最后由 $Z_1 = X W_1 + b_1$ 计算输入层到隐藏层的权重与偏置梯度:

∂L / ∂W1 = Xᵀ · δ1         # 维度: (2, N) · (N, 4) = (2, 4)
∂L / ∂b1 = Σ_rows (δ1)     # 维度: (1, 4),沿样本轴对梯度进行求和
不用框架,仅用NumPy在Python中手动实现简单神经网络
图 3:反向传播链式求导公式与各层权重梯度计算流程

五、完整的 Python + NumPy 代码实现

接下来我们将上述数学公式完整转化为工程代码。整个实现不引入任何深度学习库,只需标准 numpy。代码封装为一个面向对象的 SimpleNeuralNetwork 类,具备清晰的前向、反向、参数更新及训练接口。

import numpy as np

# 设置随机数种子以确保实验结果可复现
np.random.seed(42)

def sigmoid(z):
    """
    Sigmoid 激活函数,加入数值截断以防止指数爆炸
    """
    z_clipped = np.clip(z, -500, 500)
    return 1.0 / (1.0 + np.exp(-z_clipped))

def sigmoid_derivative(a):
    """
    Sigmoid 导数计算。输入 a 必须是已经过 Sigmoid 激活的输出值
    公式: a * (1 - a)
    """
    return a * (1.0 - a)

class SimpleNeuralNetwork:
    def __init__(self, input_dim=2, hidden_dim=4, output_dim=1, lr=1.0):
        """
        初始化双层全连接神经网络参数
        """
        self.lr = lr
        
        # 使用 Xavier / He 初始化权重,避免因随机数过大导致神经元饱和进入死区
        self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim)
        self.b1 = np.zeros((1, hidden_dim))
        
        self.W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim)
        self.b2 = np.zeros((1, output_dim))
        
        # 缓存中间变量,便于反向传播复用
        self.X = None
        self.Z1 = None
        self.A1 = None
        self.Z2 = None
        self.A2 = None
        
        # 梯度容器
        self.dW1 = None
        self.db1 = None
        self.dW2 = None
        self.db2 = None

    def forward(self, X):
        """
        前向传播
        """
        self.X = X
        # 隐藏层:线性组合后输入激活函数
        self.Z1 = np.dot(self.X, self.W1) + self.b1
        self.A1 = sigmoid(self.Z1)
        
        # 输出层:线性组合后输入激活函数
        self.Z2 = np.dot(self.A1, self.W2) + self.b2
        self.A2 = sigmoid(self.Z2)
        return self.A2

    def compute_loss(self, Y):
        """
        计算均方误差 (MSE) 损失
        """
        N = Y.shape[0]
        loss = np.sum((self.A2 - Y) ** 2) / (2.0 * N)
        return loss

    def backward(self, Y):
        """
        反向传播:根据链式法则推导并计算各参数梯度
        """
        N = Y.shape[0]
        
        # 1. 输出层误差项 delta2
        # dL/dA2 = (A2 - Y) / N, dA2/dZ2 = A2 * (1 - A2)
        delta2 = ((self.A2 - Y) / N) * sigmoid_derivative(self.A2)
        
        # 计算 W2 和 b2 的梯度
        self.dW2 = np.dot(self.A1.T, delta2)
        self.db2 = np.sum(delta2, axis=0, keepdims=True)
        
        # 2. 隐藏层误差项 delta1
        # 误差通过 W2 权重反向投影,并乘以隐藏层的激活函数导数
        dA1 = np.dot(delta2, self.W2.T)
        delta1 = dA1 * sigmoid_derivative(self.A1)
        
        # 计算 W1 和 b1 的梯度
        self.dW1 = np.dot(self.X.T, delta1)
        self.db1 = np.sum(delta1, axis=0, keepdims=True)

    def update_params(self):
        """
        应用最速梯度下降法更新权重与偏置
        """
        self.W1 -= self.lr * self.dW1
        self.b1 -= self.lr * self.db1
        self.W2 -= self.lr * self.dW2
        self.b2 -= self.lr * self.db2

    def train(self, X, Y, epochs=10000, print_interval=2000):
        """
        完整训练迭代主循环
        """
        history = []
        for epoch in range(1, epochs + 1):
            # 步骤1:前向推理
            self.forward(X)
            
            # 步骤2:损失监控
            loss = self.compute_loss(Y)
            history.append(loss)
            
            # 步骤3:反向传播求导
            self.backward(Y)
            
            # 步骤4:参数更新
            self.update_params()
            
            if epoch % print_interval == 0 or epoch == 1:
                print(f"迭代轮次 (Epoch) {epoch:5d} | 损失 (Loss): {loss:.6f}")
        return history

    def predict(self, X):
        """
        使用训练好的网络进行前向推理并二值化输出
        """
        prob = self.forward(X)
        return (prob >= 0.5).astype(int), prob

六、模型训练与结果验证

现在准备异或(XOR)问题的真实数据并启动训练。XOR 规则为:两个输入同号输出 0,异号输出 1。

# 1. 准备 XOR 训练集
X_train = np.array([
    [0, 0],
    [0, 1],
    [1, 0],
    [1, 1]
], dtype=float)

Y_train = np.array([
    [0],
    [1],
    [1],
    [0]
], dtype=float)

# 2. 实例化并配置超参数
# 学习率设为 2.0(由于批次样本极少且模型小,较大恒定学习率有助于快速脱离鞍点)
model = SimpleNeuralNetwork(input_dim=2, hidden_dim=4, output_dim=1, lr=2.0)

# 3. 运行训练
print(">>> 开始神经网络训练:")
loss_history = model.train(X_train, Y_train, epochs=10000, print_interval=2000)

# 4. 预测验证
print("\n>>> 训练完成,模型预测结果验证:")
binary_preds, raw_probs = model.predict(X_train)
for i in range(len(X_train)):
    x_sample = X_train[i].astype(int)
    y_true = int(Y_train[i][0])
    y_pred = int(binary_preds[i][0])
    prob = float(raw_probs[i][0])
    print(f"输入: {x_sample} -> 目标: {y_true} | 预测概率: {prob:.4f} | 判定: {y_pred} [{'正确' if y_true == y_pred else '错误'}]")

实际控制台运行输出(示例)

>>> 开始神经网络训练:
迭代轮次 (Epoch)     1 | 损失 (Loss): 0.141852
迭代轮次 (Epoch)  2000 | 损失 (Loss): 0.003114
迭代轮次 (Epoch)  4000 | 损失 (Loss): 0.000628
迭代轮次 (Epoch)  6000 | 损失 (Loss): 0.000308
迭代轮次 (Epoch)  8000 | 损失 (Loss): 0.000196
迭代轮次 (Epoch) 10000 | 损失 (Loss): 0.000140

>>> 训练完成,模型预测结果验证:
输入: [0 0] -> 目标: 0 | 预测概率: 0.0125 | 判定: 0 [正确]
输入: [0 1] -> 目标: 1 | 预测概率: 0.9842 | 判定: 1 [正确]
输入: [1 0] -> 目标: 1 | 预测概率: 0.9839 | 判定: 1 [正确]
输入: [1 1] -> 目标: 0 | 预测概率: 0.0151 | 判定: 0 [正确]

从输出中可以清晰观察到,随着迭代步数增加,Loss 从初期的 0.141852 迅速下降至 0.000140;在测试输入上,模型给出的概率极度接近真实标签,100% 正确分类了全部样本。

不用框架,仅用NumPy在Python中手动实现简单神经网络
图 4:神经网络在迭代训练中的四步闭环执行流程

七、实现中的常见问题与避坑指南

在脱离成熟框架手动编写神经网络时,由于缺乏自动求导系统和内建保护,开发者极易遇到以下几个典型陷阱:

易错环节 具体表现与成因 正确解决方案
权重全零初始化 如果将权重 $W$ 初始化为全 0,同一层中的所有神经元在前向和反向过程中将得到完全相同的梯度,导致“对称性破坏失效”(Symmetry Breaking Failure),多神经元退化为一个。 使用小方差的高斯随机分布(如 np.random.randn(...) * scale)进行随机初始化,打破对称性。
矩阵转置与维度错误 在计算 np.dot(X.T, delta1) 时,若漏写转置 .T 或维度次序颠倒,将导致形如 shapes (2,4) and (4,1) not aligned 的矩阵不匹配报错。 时刻牢记法则:求参数 $W_{in, out}$ 的梯度时,一定是 (输入激活值)ᵀ · (当前层误差δ),维度严格等于 $(in, out)$。
Sigmoid 指数溢出 当未激活值 $z$ 绝对值过大(如 $z < -800$)时,np.exp(-z) 会触发 Python 的 RuntimeWarning: overflow encountered in exp。 在计算指数前使用 np.clip(z, -500, 500) 将输入值约束在安全实数区间。
偏置求和轴未保持维度 对偏置求导时若写成 np.sum(delta),结果会退化为标量,在下一次矩阵相加时无法正确广播给指定形状。 务必声明 axis=0, keepdims=True,确保偏置导数始终保留 (1, K) 的二维形状。

八、总结与延伸思考

通过仅使用 NumPy 手写双层神经网络,我们清晰地拆解了现代深度学习框架背后最本质的计算骨架:

  • 前向计算本质上是嵌套的复合函数计算,矩阵乘法负责特征空间维度的变换,激活函数负责引入扭曲空间的非线性能力。
  • 反向传播本质上是复合函数的导数链式法则,利用矩阵伴随与转置将误差逐层回传。
  • 深度学习框架(如 PyTorch、TensorFlow)的核心价值在于计算图抽象与自动微分(Autograd)机制,它们免去了手动推导与书写梯度的繁琐工作,但其底层的数值计算原理与我们在本文中手动实现的 NumPy 代码完全一致。

掌握了双层网络的实现原理后,读者可以继续尝试将激活函数替换为 ReLU、将损失函数升级为交叉熵(Cross-Entropy),或者拓展为支持任意层数、带有动量(Momentum)和 Adam 优化器的通用多层感知机。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表