跳至正文
-
关注
IT极限技术分享汇

ITmax

IT极限技术分享汇

ITmax

  • 首页
  • 商店
  • 我的帐户
  • 结账
  • 购物车
  • 首页
  • 商店
  • 我的帐户
  • 结账
  • 购物车
关

搜索

技术分享

神经网络入门:从 XOR 问题理解前馈神经网络

作者 itmax
2026年7月30日 10 分钟阅读
0

本文档基于XOR问题,循序渐进地讲解神经网络的基本原理、数学推导和代码实现。
适合有一定编程基础,但初次接触神经网络的程序员阅读。


目录

  1. 神经网络是什么?
  2. XOR 问题:为什么需要神经网络?
  3. 网络结构:三层的秘密

    • 3.1 层 (Layer) 的概念
    • 3.2 为什么隐藏层需要 4 个神经元?
    • 3.3 网络参数数量
    • 3.4 NeuralNet 结构体的设计意图
    • 3.5 维度为什么这么定?
    • 3.6 静态数组的取舍
    • 3.7 通用层抽象
  4. 神经元内部:加权求和与激活函数
  5. 正向传播:信号如何流动
  6. 损失函数:如何衡量对错
  7. 反向传播:误差如何逆流
  8. 梯度下降:如何调整参数
  9. 训练循环:重复的力量
  10. 代码逐段精读
  11. 运行与解读结果
  12. 延伸思考与改进方向

    • 12.1 代码改进
    • 12.2 数学层面改进
    • 12.3 更一般的神经网络模式
    • 12.4 从 C 到深度学习框架
    • 12.5 从 NeuralNet 到通用框架

1. 神经网络是什么?

1.1 直观理解

神经网络 (Neural Network) 是一种受生物神经系统启发的计算模型。它由大量简单的处理单元(称为神经元,neuron)相互连接而成,通过调整连接强度来从数据中”学习”模式。

你可以把它想象成一个极其复杂的函数逼近器:

神经网络可以近似任何数学函数 —— 只要给足数据和合适的网络结构。

1.2 生物类比

生物神经元 人工神经元
树突(接收信号) 输入值 $x_1, x_2, …, x_n$
突触(连接强度) 权重 $w_1, w_2, …, w_n$
细胞体(整合信号) 加权求和 $/sum w_i x_i + b$
轴突(输出信号) 激活函数输出 $a = f(/sum w_i x_i + b)$

1.3 神经网络的核心思想

输入数据 → 加权求和 → 激活函数 → 输出结果 → 计算误差 → 反向调整权重 → 重复

神经网络”学习”的本质是:不断调整权重 (weights) 和偏置 (biases),使得输出结果尽可能接近正确答案。


2. XOR 问题:为什么需要神经网络?

2.1 什么是 XOR?

XOR(异或,eXclusive OR) 是一个二元逻辑运算:

输入 A 输入 B A XOR B
0 0 0
0 1 1
1 0 1
1 1 0

规则很简单:两个输入不同时输出 1,相同时输出 0。

2.2 为什么 XOR 需要神经网络?

如果我们将 XOR 的四种情况画在二维平面上:

输出 0: (0,0) 和 (1,1)
输出 1: (0,1) 和 (1,0)

你会发现:无法用一条直线将两类点分开。

  • AND(与)、OR(或)是线性可分的 → 可以用一条直线分开
  • XOR 是线性不可分的 → 单层感知机(没有隐藏层)永远无法解决

这就是为什么 XOR 成为神经网络领域的经典入门问题:它证明了单层网络的局限性,也展示了多层网络(含隐藏层)的强大。

1969 年,Minsky 和 Papert 在《感知机》一书中指出单层感知机无法解决 XOR 问题,这直接导致了人工智能的第一个”寒冬”。直到 1980 年代反向传播算法的普及,多层网络才解决了这一问题。

2.3 解决方法

我们的策略是增加一层隐藏层,让网络先学习一个非线性的特征表示,再做分类。

输入层 (2个)  →  隐藏层 (4个)  →  输出层 (1个)
    x1 ───┐
          ├─── h1 ───┐
    x2 ───┤          │
          ├─── h2 ───┤
          │          ├─── y
          ├─── h3 ───┤
          │          │
          └─── h4 ───┘

隐藏层的作用相当于特征提取器 —— 将输入数据映射到一个新的特征空间,在这个新空间中数据变得线性可分。


3. 网络结构:三层的秘密

3.1 层 (Layer) 的概念

本程序使用一个三层前馈神经网络:

输入层 (Input Layer)     隐藏层 (Hidden Layer)     输出层 (Output Layer)
    size=2                    size=4                    size=1
  • 输入层:接收原始数据,不进行计算。每个输入节点代表一个特征。
  • 隐藏层:中间计算层,提取和组合特征。之所以叫”隐藏”,是因为它的值不是直接可见的输入或输出。
  • 输出层:产生最终预测结果。

为什么叫”前馈” (Feedforward)?
因为信号只沿着一个方向流动:从输入层到隐藏层再到输出层,没有循环或反馈连接。与之相对的是”循环神经网络” (RNN),它有反馈连接。

3.2 为什么隐藏层需要 4 个神经元?

这不是固定的数字。我们选择 4 个是因为:

  • XOR 有 4 种输入模式 $(0,0), (0,1), (1,0), (1,1)$
  • 理论上 4 个隐藏神经元足够为每种模式分配一个特征表示
  • 实践中 2~4 个即可解决 XOR 问题

隐藏层神经元数量是一个超参数 (hyperparameter),需要根据问题复杂度来调整。太少的神经元学不会模式,太多的神经元会导致过拟合或浪费计算资源。

3.3 网络参数数量

层与层之间的全连接 (fully connected) 意味着每个神经元都与前一层的所有神经元相连。

输入层 → 隐藏层: 4 × 2 = 8 个权重 + 4 个偏置 = 12 个参数
隐藏层 → 输出层: 1 × 4 = 4 个权重 + 1 个偏置 = 5 个参数
总计: 17 个参数

这 17 个参数就是神经网络需要”学习”的全部内容。你可以想象:神经网络的训练本质上就是在 17 维空间中寻找最优参数组合。


3.4 NeuralNet 结构体的设计意图:把”层”映射成数据结构

到目前为止,我们讨论的都是网络结构和数学公式。真正把这些东西放进代码里的关键,就是 xor_nn.c 中的这个结构体:

typedef struct {
    /* --- 第一层 (输入层 -> 隐藏层) 的参数 --- */
    double hidden_weights[HIDDEN_SIZE][INPUT_SIZE];  /* 权重矩阵:4×2 */
    double hidden_biases[HIDDEN_SIZE];              /* 偏置向量:4 */

    /* --- 第二层 (隐藏层 -> 输出层) 的参数 --- */
    double output_weights[OUTPUT_SIZE][HIDDEN_SIZE]; /* 权重矩阵:1×4 */
    double output_biases[OUTPUT_SIZE];              /* 偏置向量:1 */

    /* --- 正向传播的中间结果 --- */
    double hidden_z[HIDDEN_SIZE], hidden_a[HIDDEN_SIZE];  /* 隐藏层:净输入 z 与激活值 a */
    double output_z[OUTPUT_SIZE], output_a[OUTPUT_SIZE];  /* 输出层:净输入 z 与激活值 a */

    /* --- 反向传播的误差项 --- */
    double output_delta[OUTPUT_SIZE];  /* 输出层误差 */
    double hidden_delta[HIDDEN_SIZE];  /* 隐藏层误差 */
} NeuralNet;

为什么这样设计?一句话概括:它的字段布局完全对应了数学推导中”层→层”的计算图。下面我们从三个角度拆解:层与矩阵的对应、中间变量的缓存设计、可学习参数与临时变量的分离。

3.4.1 层与矩阵的对应:二维数组如何表示全连接

在数学上,第 1 层(输入→隐藏)的变换可以写成:

$$/vec{z_1} = W_1 /cdot /vec{x} + /vec{b_1}$$

其中:

  • $/vec{x}$ 是形状 $(2, 1)$ 的列向量
  • $W_1$ 是形状 $(4, 2)$ 的矩阵
  • $/vec{b_1}$ 是形状 $(4, 1)$ 的偏置向量
  • $/vec{z_1}$ 是形状 $(4, 1)$ 的隐藏层净输入

在 C 语言里,用二维数组 hidden_weights[HIDDEN_SIZE][INPUT_SIZE](即 hidden_weights[4][2])来存 $W_1$ 是最自然的:

hidden_weights[i][j]  表示  隐藏层第 i 个神经元 接收 输入层第 j 个神经元 的权重

同理,output_weights[OUTPUT_SIZE][HIDDEN_SIZE] 即 output_weights[1][4],表示输出层(1 个神经元)接收隐藏层 4 个神经元的权重。这种安排和矩阵乘法的顺序一致:

for (int neuron_idx = 0; neuron_idx < HIDDEN_SIZE; neuron_idx++) {
    double weighted_sum = 0.0;
    for (int input_idx = 0; input_idx < INPUT_SIZE; input_idx++)
        weighted_sum += net->hidden_weights[neuron_idx][input_idx] * input[input_idx];  // 矩阵 hidden_weights 的第 neuron_idx 行 与 向量 input 做点积
    weighted_sum += net->hidden_biases[neuron_idx];
    net->hidden_z[neuron_idx] = weighted_sum;
    net->hidden_a[neuron_idx] = sigmoid(weighted_sum);
}

如果换成行优先 / 列优先的存储方式,内层循环的下标顺序就要跟着变。这里选择 W[dst][src],意思是目标神经元作为第一维,源神经元作为第二维,好处是:

  • 外层遍历目标神经元,内层累加其所有输入,符合人类的”先定位神经元,再看它有哪些连接”的直觉。
  • 在 CPU 缓存上,按行优先访问 hidden_weights[i][*] 也通常是连续的,效率较好。

小提示:很多深度学习框架(如 NumPy、PyTorch)默认习惯用 W[out, in]。C 语言数组 W[i][j] 的语义也正好对应 out=i, in=j。

3.4.2 为什么偏置是单独的一维数组?

你可能会想:把偏置放进权重矩阵的最后一列不就行了吗?也就是给每个样本追加一个恒为 1 的输入,这样可以统一写成一次矩阵乘法。这种技巧确实在数学推导中很常见,但在入门代码里:

  1. 分开存储更直观:权重 $W$ 负责”输入对输出的影响”,偏置 $b$ 负责”基线激活水平”,两者职责清晰。
  2. 代码更对称:每个神经元都有 W[i][*] 和 b[i],公式 $/sum W x + b$ 可以直接对应两行代码。
  3. 避免输入造假样本:不需要把训练数据扩展成 {x1, x2, 1},XOR 的输入仍是自然的二维数组。

因此,偏置单独用 hidden_biases[HIDDEN_SIZE]、output_biases[OUTPUT_SIZE] 存储,是可读性优先的设计。

3.4.3 中间变量 z、a、delta 为什么也放在结构体里?

神经网络的计算不是一次性的:

  • 正向传播时计算 hidden_z, hidden_a, output_z, output_a
  • 反向传播时又要用 hidden_a(输出层权重梯度)和 output_a(损失对输出求导)
  • 更新权重时还要用 hidden_delta, output_delta 和 hidden_a / x

如果把这些临时变量都作为函数参数传来传去,代码会变成这样:

void backward(NeuralNet *network, double *input, double target,
              double *hidden_z, double *hidden_a, double *output_z, double *output_a,
              double *hidden_delta, double *output_delta);

非常冗长,而且容易传错。把它们塞进 NeuralNet 结构体,相当于给网络加了一层”工作内存”:

字段 正向传播写入 反向传播读取 更新权重读取
hidden_z 是 用于计算 sigmoid_derivative(hidden_z)(本代码用 hidden_a 间接存) 否
hidden_a 是 是(输出层梯度 = δ2 · hidden_a) 是(更新 output_weights)
output_z 是 是(计算 sigmoid 导数) 否
output_a 是 是(计算损失和 δ2) 否
hidden_delta 否 写入 是(更新 hidden_weights)
output_delta 否 写入 是(更新 output_weights)

设计模式:这是一种”用空间换便利 + 用封装换正确性”的策略。类似 PyTorch 里 Tensor 会持有 data 和 grad,训练时前向保存激活,反向直接复用。

3.4.4 可学习参数 vs 临时变量的分离

再看一眼结构体,你会发现它天然分成了两大组:

typedef struct {
    /* ===== 可学习参数(训练要改的) ===== */
    double hidden_weights[HIDDEN_SIZE][INPUT_SIZE];  double hidden_biases[HIDDEN_SIZE];
    double output_weights[OUTPUT_SIZE][HIDDEN_SIZE]; double output_biases[OUTPUT_SIZE];

    /* ===== 前向/反向计算的临时状态(训练要用,但本身不保存) ===== */
    double hidden_z[HIDDEN_SIZE], hidden_a[HIDDEN_SIZE];
    double output_z[OUTPUT_SIZE], output_a[OUTPUT_SIZE];
    double output_delta[OUTPUT_SIZE], hidden_delta[HIDDEN_SIZE];
} NeuralNet;

这两组数据的”生命周期”不同:

  • 可学习参数:初始化后一直存在,直到训练结束。它们就是模型的”知识”。
  • 临时状态:每个样本前向、反向、更新后,上一轮样本的值就被覆盖了。它们只服务于当前这一步计算。

这种分离的好处是:

  • 保存模型时,只需把 hidden_weights, hidden_biases, output_weights, output_biases 写进文件;
  • 推理(inference)时,只需要 hidden_weights, hidden_biases, output_weights, output_biases 和临时的 hidden_z, hidden_a, output_z, output_a,不需要 delta;
  • 多人协作或调试时,一眼就能看出”哪些是真正的参数,哪些只是计算过程量”。

3.5 维度为什么这么定?从形状看代码

NeuralNet 的维度不是随意写的,它们由三个宏定义决定:

#define INPUT_SIZE   2
#define HIDDEN_SIZE  4
#define OUTPUT_SIZE  1

把它们写成表格,可以看到整个网络的”形状”(shape):

数据 数学形状 C 数组维度 元素总数
输入 $x$ $(2, 1)$ x[INPUT_SIZE] 2
隐藏层权重 $W_1$ $(4, 2)$ hidden_weights[4][2] 8
隐藏层偏置 $b_1$ $(4, 1)$ hidden_biases[4] 4
隐藏层净输入 $z_1$ $(4, 1)$ hidden_z[4] 4
隐藏层激活 $a_1$ $(4, 1)$ hidden_a[4] 4
输出层权重 $W_2$ $(1, 4)$ output_weights[1][4] 4
输出层偏置 $b_2$ $(1, 1)$ output_biases[1] 1
输出层净输入 $z_2$ $(1, 1)$ output_z[1] 1
输出层激活 $a_2$ / 预测值 $(1, 1)$ output_a[1] 1
隐藏层误差 $/delta_1$ $(4, 1)$ hidden_delta[4] 4
输出层误差 $/delta_2$ $(1, 1)$ output_delta[1] 1

这个形状矩阵是理解代码的关键:每当你写循环时,先想清楚”这个数组代表哪个向量/矩阵,形状是什么”。例如:

  • 输出层只有 1 个神经元,所以 output_a[0] 就是预测值;
  • 隐藏层有 4 个神经元,所以 hidden_a[0] 到 hidden_a[3] 分别对应 4 个”特征提取器”的输出;
  • 更新 hidden_weights 时,外层循环是 i < HIDDEN_SIZE,内层是 j < INPUT_SIZE,正好覆盖 4×2 的矩阵。

记忆口诀:hidden_weights[i][j] 中 i 是目标(隐藏层神经元),j 是来源(输入特征)。维度总是 [目标层大小][来源层大小]。

3.6 为什么不直接用动态内存?静态数组的取舍

本代码用的是静态数组(编译期已知大小),而不是 malloc 动态分配。原因:

  1. 简单、可预测:对于固定层大小的 XOR 网络,编译时就知道所有内存需求,无需处理内存泄漏。
  2. 缓存友好:结构体在栈或全局区连续分配,访问速度通常优于分散的堆指针。
  3. 教学清晰:读者不需要分心学习内存管理,能专注于神经网络算法本身。

但静态数组的代价是缺乏灵活性:

  • 如果你想把隐藏层改成 8 个神经元,必须重新编译。
  • 如果你想加第三个隐藏层,结构体无法直接复用,需要重新设计。

因此,本代码是针对固定网络结构的教育实现,不是通用深度学习框架。理解了静态数组版本后,再改成动态层结构会更自然。

3.7 一个更通用的层抽象长什么样?

如果你把隐藏层数量、每层大小都变成可配置的,就可以把 NeuralNet 抽象成一组层 (Layer)。每个层只关心:

  • 输入维度 input_size
  • 输出维度 output_size
  • 权重矩阵 weights(大小 output_size × input_size)
  • 偏置向量 bias(大小 output_size)
  • 本层的净输入 z 和激活 a
  • 本层的误差项 delta
  • 一个激活函数指针
typedef struct {
    int input_size;
    int output_size;

    double *weights;   // 一维展平,长度 = output_size * input_size
    double *bias;      // 长度 = output_size

    double *z;         // 净输入,长度 = output_size
    double *a;         // 激活值,长度 = output_size
    double *delta;     // 误差项,长度 = output_size

    double (*activation)(double value);
    double (*activation_derivative)(double activation_value);
} Layer;

整个网络就是一个层数组:

typedef struct {
    Layer *layers;
    int num_layers;     // 例如 2:输入→隐藏、隐藏→输出
} DeepNet;

此时:

  • 前向传播就是按顺序遍历每一层 forward(layers[i]);
  • 反向传播先计算最后一层 delta,再向前逐层传播;
  • 加层、改层大小只需要改配置数组,不需要改结构体。

本教程的 NeuralNet 可以看作这个通用层结构的特例——两层、隐藏层 4 个神经元、固定 sigmoid。先吃透特例,再推广到通用框架,是学习神经网络最稳妥的路径。


4. 神经元内部:加权求和与激活函数

4.1 单个神经元的计算过程

一个神经元做两件事:

  1. 加权求和:将所有输入乘以对应权重后求和,再加上偏置
  2. 激活:将求和结果通过激活函数,产生最终输出
        输入1 ── w1 ──┐
        输入2 ── w2 ──┤
                      ├──→ Σ(wi·xi) + b ──→ f(Σ) ──→ 输出
        输入3 ── w3 ──┤
         ...          │
        输入n ── wn ──┘

数学表达式:

$$z = /sum_{i=1}^{n} w_i x_i + b$$

$$a = f(z)$$

其中 $f$ 是激活函数。

4.2 权重和偏置的几何意义

权重 (Weight) $w_i$ 表示对应输入 $x_i$ 对神经元输出的影响程度:

  • 权重为正:输入增大时输出增大(兴奋性连接)
  • 权重为负:输入增大时输出减小(抑制性连接)
  • 权重的绝对值越大:该输入的影响越显著

偏置 (Bias) $b$ 相当于数学直线方程 $y = wx + b$ 中的 $b$(截距项),它决定了神经元在没有任何有效输入时的输出倾向。

换个角度理解:

$$z = /sum w_i x_i + b = /vec{w} /cdot /vec{x} + b$$

这是一个超平面(在高维空间中的”分界线”),权重 $w$ 决定了平面的方向,偏置 $b$ 决定了平面的位置。

4.3 激活函数:为什么需要”非线性”?

如果没有激活函数,无论堆叠多少层神经网络,最终都等价于一个线性变换:

$$W_2(W_1x + b_1) + b_2 = (W_2W_1)x + (W_2b_1 + b_2) = W’x + b’$$

这仍然是线性函数!多层线性 = 单层线性。

激活函数引入非线性,让网络能够学习复杂的模式。常见的激活函数有:

激活函数 公式 输出范围 特点
Sigmoid $1/(1+e^{-x})$ (0, 1) 平滑,适合二分类输出层
Tanh $(e^x-e^{-x})/(e^x+e^{-x})$ (-1, 1) 零中心,比 Sigmoid 梯度更好
ReLU $/max(0, x)$ [0, ∞) 计算快,缓解梯度消失,最常用
Leaky ReLU $/max(0.01x, x)$ (-∞, ∞) ReLU 的改进,负值不消失

4.4 Sigmoid 函数详解

本程序使用了 Sigmoid 激活函数:

$$sigmoid(x) = /frac{1}{1 + e^{-x}}$$

其图像呈 S 形:

输出
 1.0 ────────────────
     |
     |          ****
 0.5 ────────**
     |   **
 0.0 ──**───────────────→ 输入

Sigmoid 的特性:

  • 将任意实数映射到 $(0, 1)$ 区间
  • $x = 0$ 时输出 $0.5$
  • $x$ 很大时输出接近 $1$(神经元”激活”)
  • $x$ 很小时输出接近 $0$(神经元”抑制”)
  • 导数计算简单:$sigmoid'(x) = sigmoid(x) /cdot (1 – sigmoid(x))$

为什么用于二分类?
因为输出在 0 到 1 之间,可以解释为”是正类的概率”。

Sigmoid 的缺点:

  • 当 $x$ 很大或很小时,梯度接近 0(梯度消失问题)
  • 输出不是零中心的,可能影响优化效率

现代神经网络通常隐藏层用 ReLU,输出层根据任务选择(二分类用 Sigmoid,多分类用 Softmax,回归用线性)。

4.5 代码对照

/* 单个神经元的计算流程 */
double weighted_sum = 0.0;                              // 1. 初始化累加器
for (int input_idx = 0; input_idx < INPUT_SIZE; input_idx++)
    weighted_sum += net->hidden_weights[neuron_idx][input_idx] * input[input_idx];               // 2. 加权求和
weighted_sum += net->hidden_biases[neuron_idx];                             // 3. 加上偏置
net->hidden_z[neuron_idx] = weighted_sum;                              // 4. 保存净输入 z
net->hidden_a[neuron_idx] = sigmoid(weighted_sum);                     // 5. 激活得到输出 a

5. 正向传播:信号如何流动

5.1 定义

正向传播 (Forward Propagation) 是指输入数据从输入层出发,逐层向前传递,最终产生输出的过程。

5.2 数学推导

第 1 步:输入层 → 隐藏层

对于隐藏层第 $i$ 个神经元:

$$z1_i = /sum_{j=1}^{2} W1_{i,j} /cdot x_j + b1_i$$

$$a1_i = /sigma(z1_i)$$

其中 $/sigma$ 表示 sigmoid 函数。

用矩阵形式表示:

$$/vec{z1} = W_1 /cdot /vec{x} + /vec{b_1}$$

$$/vec{a1} = /sigma(/vec{z1})$$

第 2 步:隐藏层 → 输出层

对于输出层(只有一个神经元):

$$z2 = /sum_{j=1}^{4} W2_{1,j} /cdot a1_j + b2$$

$$a2 = /sigma(z2)$$

矩阵形式:

$$z2 = W_2 /cdot /vec{a1} + b_2$$

$$a2 = /sigma(z2)$$

5.3 直观理解

正向传播就是输入信号在网络中逐层传递的过程。每一层都:

  1. 接收上一层的输出
  2. 用当前层的权重进行加权求和
  3. 加上偏置
  4. 通过激活函数压缩
  5. 传递给下一层
flowchart LR
    x1["x₁"] --> h1["h₁: z₁₁=Σw·x+b → σ(z₁₁)"]
    x1 --> h2["h₂"]
    x2["x₂"] --> h1
    x2 --> h2
    x1 --> h3["h₃"]
    x1 --> h4["h₄"]
    x2 --> h3
    x2 --> h4
    h1 --> y["y: z₂=Σw·a₁+b → σ(z₂)"]
    h2 --> y
    h3 --> y
    h4 --> y

5.4 为什么叫”前馈”?

前馈 (Feedforward) 的含义是信息单向流动,没有回路:

x → z1 → σ(z1) = a1 → z2 → σ(z2) = a2 → ŷ

与之对应的是循环神经网络 (RNN),它的隐藏层会接收自己的历史输出作为当前输入的一部分,形成循环。

5.5 代码中的实现

void forward(NeuralNet *network, double *input) {
    // 第一步:输入层 → 隐藏层
    for (int neuron_idx = 0; neuron_idx < HIDDEN_SIZE; neuron_idx++) {
        double weighted_sum = 0.0;
        for (int input_idx = 0; input_idx < INPUT_SIZE; input_idx++)
            weighted_sum += network->hidden_weights[neuron_idx][input_idx] * input[input_idx];    // 加权求和
        weighted_sum += network->hidden_biases[neuron_idx];                                       // 加偏置
        network->hidden_z[neuron_idx] = weighted_sum;                                        // 保存净输入
        network->hidden_a[neuron_idx] = sigmoid(weighted_sum);                               // 激活
    }

    // 第二步:隐藏层 → 输出层
    for (int output_idx = 0; output_idx < OUTPUT_SIZE; output_idx++) {
        double weighted_sum = 0.0;
        for (int hidden_idx = 0; hidden_idx < HIDDEN_SIZE; hidden_idx++)
            weighted_sum += network->output_weights[output_idx][hidden_idx] * network->hidden_a[hidden_idx];  // 以上一层的激活值作为输入
        weighted_sum += network->output_biases[output_idx];
        network->output_z[output_idx] = weighted_sum;
        network->output_a[output_idx] = sigmoid(weighted_sum);                               // 最终输出
    }
}

6. 损失函数:如何衡量对错

6.1 什么是损失函数?

损失函数 (Loss Function) 量化了模型的预测值与真实值之间的差距。损失越小,说明预测越准确。

训练神经网络的目标就是最小化损失函数。

6.2 均方误差 (Mean Squared Error, MSE)

本程序使用均方误差的一半作为损失函数:

$$L = /frac{1}{2}(/hat{y} – y)^2$$

其中 $/hat{y}$ 是预测值,$y$ 是真实目标值。

为什么用平方?

  • 误差为正时惩罚为正,误差为负时惩罚也为正(平方消除了符号)
  • 放大较大误差(平方放大了偏离程度)
  • 数学上可导,方便梯度计算

为什么乘以 $/frac{1}{2}$?
这是为了后续求导时抵消平方项带来的系数 2,使梯度表达式更简洁:

$$/frac{/partial L}{/partial /hat{y}} = /hat{y} – y$$

而不是 $2(/hat{y} – y)$。

6.3 平均损失

当有多个训练样本时,我们对所有样本的损失取平均:

$$/text{Average Loss} = /frac{1}{n} /sum_{s=1}^{n} /frac{1}{2}(/hat{y}_s – y_s)^2$$

这称为均方误差 (MSE)。

6.4 代码中的损失计算

double difference = network->output_a[0] - targets[sample_idx];   // 预测值 - 目标值
loss += 0.5 * difference * difference;                    // 累加损失
// 每 1000 轮打印平均损失
printf("Epoch %d, Loss = %.6f/n", epoch, loss / num_samples);

训练过程中,你会看到损失逐渐下降:

Epoch 0,     Loss = 0.134335
Epoch 1000,  Loss = 0.003974
Epoch 10000, Loss = 0.000091
Epoch 999000, Loss = 0.000001

损失接近 0 意味着网络已经学会了 XOR 函数。

6.5 其他常见损失函数

损失函数 公式 适用场景
均方误差 (MSE) $/frac{1}{n}/sum(/hat{y}-y)^2$ 回归问题
平均绝对误差 (MAE) $/frac{1}{n}/sum/ /hat{y}-y / $ 回归问题(对异常值更鲁棒)
交叉熵 (Cross Entropy) $-[y/log/hat{y} + (1-y)/log(1-/hat{y})]$ 二分类问题
Hinge Loss $/max(0, 1 – y /cdot /hat{y})$ 支持向量机

7. 反向传播:误差如何逆流

7.1 核心思想

反向传播 (Backpropagation) 是训练神经网络的核心算法。它的作用是:

计算每个参数(权重和偏置)对最终损失的”贡献度”(即梯度),从而知道每个参数应该朝哪个方向调整。

反向传播的本质是链式法则 (Chain Rule) 的应用 —— 这是微积分中计算复合函数导数的方法。

7.2 直观理解

正向传播:    x → z1 → a1 → z2 → a2 → ŷ → 损失 L
反向传播:    x ← z1 ← a1 ← z2 ← a2 ← ŷ ← 损失 L
                     ↑                      ↑
                误差从隐藏层         误差从输出层
                反向传递而来         直接计算得到

关键 insight: 输出层的误差可以直接比较预测值和目标值得到。但隐藏层的误差不能直接得到 —— 它需要通过输出层的误差反向传播回来。

7.3 数学推导(链式法则)

我们先梳理整个计算图:

$$/begin{aligned}
z1_i &= /sum_j W1_{i,j} x_j + b1_i /
a1_i &= /sigma(z1_i) /
z2 &= /sum_j W2_{1,j} a1_j + b2 /
a2 &= /sigma(z2) /
L &= /frac{1}{2}(a2 – y)^2
/end{aligned}$$

7.3.1 输出层的梯度

首先计算输出层的误差项 $/delta_2$(输出层梯度):

$$/delta_2 = /frac{/partial L}{/partial z2} = /frac{/partial L}{/partial a2} /cdot /frac{/partial a2}{/partial z2}$$

其中:

$$/frac{/partial L}{/partial a2} = a2 – y$$

$$/frac{/partial a2}{/partial z2} = /sigma'(z2) = /sigma(z2) /cdot (1 – /sigma(z2)) = a2 /cdot (1 – a2)$$

所以:

$$/delta_2 = (a2 – y) /cdot a2 /cdot (1 – a2)$$

有了 $/delta_2$,我们可以计算输出层参数的梯度:

$$/frac{/partial L}{/partial W2_{1,j}} = /frac{/partial L}{/partial z2} /cdot /frac{/partial z2}{/partial W2_{1,j}} = /delta_2 /cdot a1_j$$

$$/frac{/partial L}{/partial b2} = /frac{/partial L}{/partial z2} /cdot /frac{/partial z2}{/partial b2} = /delta_2$$

7.3.2 隐藏层的梯度

现在将误差传播到隐藏层。隐藏层第 $i$ 个神经元的误差项 $/delta_{1,i}$:

$$/delta_{1,i} = /frac{/partial L}{/partial z1_i} = /frac{/partial L}{/partial a1_i} /cdot /frac{/partial a1_i}{/partial z1_i}$$

其中 $/frac{/partial a1_i}{/partial z1_i} = /sigma'(z1_i) = a1_i /cdot (1 – a1_i)$。

而 $/frac{/partial L}{/partial a1_i}$ 需要从输出层传回来:

$$/frac{/partial L}{/partial a1_i} = /frac{/partial L}{/partial z2} /cdot /frac{/partial z2}{/partial a1_i} = /delta_2 /cdot W2_{1,i}$$

所以:

$$/delta_{1,i} = /delta_2 /cdot W2_{1,i} /cdot a1_i /cdot (1 – a1_i)$$

有了 $/delta_{1,i}$,隐藏层参数的梯度为:

$$/frac{/partial L}{/partial W1_{i,j}} = /delta_{1,i} /cdot x_j$$

$$/frac{/partial L}{/partial b1_i} = /delta_{1,i}$$

7.4 层与层之间的通用规律

仔细观察上面的推导,你会发现一个优美的模式:

误差从上层传下来的值 = (上层的误差项) × (连接权重)
本层误差项 = 从上层传下来的值 × 本层激活函数的导数
参数梯度 = 本层误差项 × 本层的输入值

这正是反向传播的通用公式,可以推广到任意深度的网络。

7.5 代码实现

void backward(NeuralNet *network, double *input, double target) {
    // 1. 计算输出层误差项
    double error = network->output_a[0] - target;
    network->output_delta[0] = error * sigmoid_derivative(network->output_a[0]);
    // 2. 将误差传播到隐藏层
    for (int neuron_idx = 0; neuron_idx < HIDDEN_SIZE; neuron_idx++) {
        double propagated_error = 0.0;
        // 误差 = 输出层误差项 × 连接权重
        propagated_error += network->output_weights[0][neuron_idx] * network->output_delta[0];
        // 隐藏层误差项 = 传回的误差 × 激活函数导数
        network->hidden_delta[neuron_idx] = propagated_error * sigmoid_derivative(network->hidden_a[neuron_idx]);
    }
}

8. 梯度下降:如何调整参数

8.1 核心思想

梯度下降 (Gradient Descent) 是一种迭代优化算法,用于最小化损失函数。

想象你在一个山谷中(损失函数曲面),你的目标是走到谷底(最小值点)。梯度告诉你在当前位置哪个方向最陡峭(上升方向),你往反方向走一步,就能下降一点。

$$新参数 = 旧参数 – 学习率 /times 梯度$$

flowchart TD
    A[随机初始化参数] --> B[正向传播: 计算预测值和损失]
    B --> C[反向传播: 计算梯度]
    C --> D[更新参数: W -= lr × 梯度]
    D --> E{损失足够小?}
    E -- 否 --> B
    E -- 是 --> F[训练完成]

8.2 学习率

学习率 (Learning Rate, lr) 控制每次参数更新的步长:

  • 学习率太大:参数更新幅度过大,可能越过最优点,在谷底附近震荡甚至发散
       ╱╲       ╱╲
      ╱  ╲     ╱  ╲
     ╱    ╲   ╱    ╲
    ╱      ╲_╱      ╲
  • 学习率太小:更新速度慢,需要很多次迭代才能收敛
     → → → → → → → →
     → → → → → → → →
  • 学习率适中:稳定下降,高效收敛
     → → →
       → → →
         → → →

本程序使用 $lr = 0.5$,对于 XOR 问题来说是一个合适的值。

8.3 梯度下降的变体

方法 每次更新使用的样本数 特点
批量梯度下降 (BGD) 全部样本 稳定但慢
随机梯度下降 (SGD) 1 个样本 快但震荡大
小批量梯度下降 (Mini-batch) 16~256 个样本 折中,最常用

本程序使用的是 SGD(每看到 1 个样本就更新一次参数)。

8.4 代码实现

void update_weights(NeuralNet *network, double *input, double learning_rate) {
    // 更新输出层: output_weights -= lr × δ2 × hidden_a
    for (int hidden_idx = 0; hidden_idx < HIDDEN_SIZE; hidden_idx++)
        network->output_weights[0][hidden_idx] -= learning_rate * network->output_delta[0] * network->hidden_a[hidden_idx];
    network->output_biases[0] -= learning_rate * network->output_delta[0];

    // 更新隐藏层: hidden_weights -= lr × δ1 × input
    for (int neuron_idx = 0; neuron_idx < HIDDEN_SIZE; neuron_idx++) {
        for (int input_idx = 0; input_idx < INPUT_SIZE; input_idx++)
            network->hidden_weights[neuron_idx][input_idx] -= learning_rate * network->hidden_delta[neuron_idx] * input[input_idx];
        network->hidden_biases[neuron_idx] -= learning_rate * network->hidden_delta[neuron_idx];
    }
}

8.5 完整的参数更新公式汇总

整理所有参数的更新规则:

参数 更新公式 含义
$W2_{1,j}$ $W2_{1,j} /leftarrow W2_{1,j} – lr /cdot /delta_2 /cdot a1_j$ 输出层权重
$b2$ $b2 /leftarrow b2 – lr /cdot /delta_2$ 输出层偏置
$W1_{i,j}$ $W1_{i,j} /leftarrow W1_{i,j} – lr /cdot /delta_{1,i} /cdot x_j$ 隐藏层权重
$b1_i$ $b1_i /leftarrow b1_i – lr /cdot /delta_{1,i}$ 隐藏层偏置

其中:$/delta_2 = (a2 – y) /cdot a2 /cdot (1 – a2)$,$/delta_{1,i} = /delta_2 /cdot W2_{1,i} /cdot a1_i /cdot (1 – a1_i)$。


9. 训练循环:重复的力量

9.1 训练流程

void train(NeuralNet *network, double inputs[][INPUT_SIZE], double targets[],
           int num_samples, int epochs, double learning_rate) {
    for (int epoch = 0; epoch < epochs; epoch++) {       // 外层:迭代多个轮次
        double loss = 0.0;
        for (int sample_idx = 0; sample_idx < num_samples; sample_idx++) {        // 内层:遍历所有样本
            forward(network, inputs[sample_idx]);          // 步骤 1: 正向传播
            backward(network, inputs[sample_idx], targets[sample_idx]); // 步骤 2: 反向传播
            update_weights(network, inputs[sample_idx], learning_rate);  // 步骤 3: 更新权重

            double difference = network->output_a[0] - targets[sample_idx];
            loss += 0.5 * difference * difference;          // 累计损失
        }
        if (epoch % 1000 == 0)
            printf("Epoch %d, Loss = %.6f/n", epoch, loss / num_samples);
    }
}

9.2 Epoch 是什么?

一个 epoch(轮次)等于遍历所有训练样本一次。

  • 训练 1,000,000 轮 = 每轮 4 个样本 = 总共 4,000,000 次参数更新
  • 每 1000 轮打印一次损失,可以看到损失逐渐下降的趋势

9.3 训练过程中的损失变化

典型的训练曲线:

损失
│
│   *
│    *
│     ****
│         ***
│            ****
│                 ***
│                     ****
│                          ****
│                               ********
│                                        ************
└──────────────────────────────────────────────────→ 训练轮次
  • 初期:损失快速下降(参数从随机值快速调整到合理范围)
  • 中期:损失缓慢下降(精细调整)
  • 后期:损失趋于平稳(接近最优解)

9.4 什么时候停止训练?

本程序硬编码了 1,000,000 轮训练。实际中通常使用以下策略:

  1. Early Stopping:当损失不再明显下降时停止
  2. 达到目标精度:当验证集精度达到阈值时停止
  3. 最大轮次限制:设置一个上限,防止无限训练

10. 代码逐段精读

10.1 头文件和宏定义

#include <stdio.h>    // 标准 IO:printf
#include <stdlib.h>   // 标准库:rand(), srand()
#include <math.h>     // 数学库:exp()
#include <time.h>     // 时间库(本程序未直接使用)

#define INPUT_SIZE   2   // 输入层:2 个神经元(x₁, x₂)
#define HIDDEN_SIZE  4   // 隐藏层:4 个神经元
#define OUTPUT_SIZE  1   // 输出层:1 个神经元(预测值)

使用 #define 定义常量是一种良好的编程习惯——如果需要改变网络结构,只需要修改这些数字即可。

10.2 神经网络结构体

typedef struct {
    // 权重和偏置(可学习参数)
    double hidden_weights[HIDDEN_SIZE][INPUT_SIZE];   // [4][2]: 隐藏层权重
    double hidden_biases[HIDDEN_SIZE];               // [4]:    隐藏层偏置
    double output_weights[OUTPUT_SIZE][HIDDEN_SIZE];  // [1][4]: 输出层权重
    double output_biases[OUTPUT_SIZE];               // [1]:    输出层偏置

    // 中间结果(在正向和反向传播中用到)
    double hidden_z[HIDDEN_SIZE], hidden_a[HIDDEN_SIZE];       // 隐藏层的净输入和激活值
    double output_z[OUTPUT_SIZE], output_a[OUTPUT_SIZE];       // 输出层的净输入和激活值
    double output_delta[OUTPUT_SIZE], hidden_delta[HIDDEN_SIZE]; // 误差项
} NeuralNet;

为什么将中间结果也存储在结构体中?

这是一种”用空间换便利”的做法:

  • hidden_z, hidden_a, output_z, output_a 在正向传播中计算,在反向传播中要用到
  • hidden_delta, output_delta 在反向传播中计算
  • 将它们存在结构体中,避免在函数间传递大量临时数组

更现代的做法是在每次前向传播时重新计算并立即用于反向传播,这样可以节省内存但增加代码复杂度。

10.3 初始化函数

void init_net(NeuralNet *network) {
    srand(42);  // 固定随机种子,确保结果可复现

    // 初始化隐藏层参数
    for (int neuron_idx = 0; neuron_idx < HIDDEN_SIZE; neuron_idx++) {
        for (int input_idx = 0; input_idx < INPUT_SIZE; input_idx++)
            network->hidden_weights[neuron_idx][input_idx] = ((double)rand() / RAND_MAX) * 2.0 - 1.0;
        network->hidden_biases[neuron_idx] = ((double)rand() / RAND_MAX) * 2.0 - 1.0;
    }

    // 初始化输出层参数(同理)
    // ...
}

初始化的重要性:

  • 如果所有权重初始化为 0,所有神经元的输出相同,梯度也相同,网络无法学习
  • 如果权重太大,经过 sigmoid 后会进入饱和区,梯度接近 0,学习停滞
  • 合理的随机初始化可以打破对称性,让不同神经元学到不同特征

本程序使用 $[-1, 1]$ 均匀分布初始化,这是一个简单有效的方法。更常用的初始化方法包括:

  • Xavier/Glorot 初始化:适合 sigmoid/tanh
  • He 初始化:适合 ReLU

10.4 指针语法解释

对于 C 语言新手,这里解释一下代码中的指针用法:

void init_net(NeuralNet *network) {
    // network 是一个指针,指向 NeuralNet 结构体
    // network->hidden_weights 等价于 (*network).hidden_weights,访问指针指向的结构体的成员
    network->hidden_weights[neuron_idx][input_idx] = ...;
}

int main() {
    NeuralNet network;      // 创建结构体变量
    init_net(&network);     // & 取地址运算符,传递变量的地址给函数
}

为什么用指针?

  • C 语言函数参数是传值的,如果不传指针,函数会复制整个结构体
  • NeuralNet 包含多个数组,复制开销很大
  • 用指针可以避免复制,直接修改原结构体

11. 运行与解读结果

11.1 编译和运行

gcc -Wall -o xor_nn.exe xor_nn.c -lm
./xor_nn.exe

参数说明:

  • -Wall:开启所有警告
  • -o xor_nn.exe:指定输出文件名
  • -lm:链接数学库(因为使用了 exp())

11.2 输出解读

Epoch 0,     Loss = 0.134335     ← 初始损失(随机参数)
Epoch 1000,  Loss = 0.003974     ← 损失快速下降
Epoch 2000,  Loss = 0.000836     ← 继续下降
Epoch 3000,  Loss = 0.000436
...
Epoch 10000, Loss = 0.000091     ← 损失降到很低
...
Epoch 999000, Loss = 0.000001    ← 几乎为 0

Testing XOR:
0 XOR 0 = 0.001062  (expected 0)    ← 接近 0 ✓
0 XOR 1 = 0.998870  (expected 1)    ← 接近 1 ✓
1 XOR 0 = 0.998873  (expected 1)    ← 接近 1 ✓
1 XOR 1 = 0.001154  (expected 0)    ← 接近 0 ✓

结果分析:

  • 预测值不是精确的 0 或 1,而是接近 0 或 1 的小数
  • 如果设置阈值为 0.5(大于 0.5 判为 1,否则判为 0),则全部正确
  • 训练更多轮次或调整学习率可以进一步提高精度

11.3 如何判断网络是否学到了?

学习成功的标志:

  • 损失持续下降到很低(如 $10^{-6}$ 量级)
  • 测试结果符合预期

学习失败的可能原因:

  • 损失不下降或上升 → 学习率太大,或代码有 bug
  • 损失下降但测试结果差 → 过拟合(在 XOR 小数据集上不常见)
  • 损失卡在较高值 → 陷入局部最优,或网络容量不足

12. 延伸思考与改进方向

12.1 代码可以改进的地方

改进方向 说明 代码修改
动态学习率 训练初期用大学习率快速下降,后期用小学习率精细调整 添加学习率衰减逻辑
动量 (Momentum) 加速收敛,减少震荡 引入动量项 $v = /gamma v + lr /cdot g$
Mini-batch 每次用多个样本计算梯度,更稳定 每次取一批样本,梯度取平均
验证集 用一部分数据验证训练效果,防止过拟合 划分训练/验证集
保存/加载模型 训练好的参数可以保存到文件 将权重写入文件

12.2 数学层面的改进

  • ReLU 激活函数:用 max(0, x) 替代 sigmoid,计算更快,缓解梯度消失
  • 交叉熵损失:对于分类问题,交叉熵通常比 MSE 效果更好
  • Softmax 输出:多分类时使用,将输出转换为概率分布
  • Batch Normalization:每层输出做标准化,加速训练
  • Dropout:训练时随机丢弃部分神经元,防止过拟合

12.3 更一般的神经网络模式

本程序的网络结构可以推广到任意层数、任意宽度:

// 通用层结构(示意)
typedef struct {
    int input_size;
    int output_size;
    double *weights;    // 一维存储权重矩阵
    double *bias;
    double *input;      // 本层的输入(上一层的激活值)
    double *z;          // 净输入
    double *output;     // 激活值
    double *delta;      // 误差项
} Layer;

typedef struct {
    Layer *layers;
    int num_layers;
    double (*activation)(double);
    double (*activation_deriv)(double);
} DeepNet;

12.4 从 C 到深度学习框架

理解了这个 C 语言实现,你就理解了所有深度学习框架(TensorFlow、PyTorch、JAX)的核心:

概念 C 代码 PyTorch 等价
定义网络 NeuralNet 结构体 nn.Module 子类
线性变换 hidden_weights*x + hidden_biases nn.Linear(2, 4)
激活函数 sigmoid() nn.Sigmoid()
损失函数 0.5*(ŷ-y)² nn.MSELoss()
反向传播 手动实现 loss.backward()(自动微分)
参数更新 手动实现 optimizer.step()

现代框架自动处理了:

  • 梯度计算(自动微分)
  • 参数更新(各种优化器)
  • 批量处理(数据加载器)
  • GPU 加速(CUDA)

但基本原理完全一致,这就是为什么理解这个 C 语言实现如此重要。

12.5 从 NeuralNet 到通用框架:结构体演进路线

如果你想把本代码改造成能处理任意层数、任意大小的项目,可以沿着下面的路线逐步重构:

阶段 1:把硬编码结构体改成按层组织

不要一下子跳到动态内存。可以先保留静态数组,但用宏定义层数和每层大小:

#define NUM_LAYERS 3          // 输入层不计入,这里是隐藏+输出
#define LAYER_SIZES {4, 1}    // 隐藏层 4 个,输出层 1 个

然后把 hidden_weights/hidden_biases/hidden_z/hidden_a/hidden_delta 和 output_weights/output_biases/output_z/output_a/output_delta 合并成数组:

typedef struct {
    double W[NUM_LAYERS-1][MAX_LAYER_SIZE][MAX_LAYER_SIZE];  // 层间权重
    double b[NUM_LAYERS-1][MAX_LAYER_SIZE];                  // 层间偏置
    double z[NUM_LAYERS-1][MAX_LAYER_SIZE];                  // 净输入
    double a[NUM_LAYERS-1][MAX_LAYER_SIZE];                  // 激活值
    double delta[NUM_LAYERS-1][MAX_LAYER_SIZE];              // 误差项
} NeuralNetV2;

这种写法仍然使用静态数组,但循环已经按”层索引”统一,代码复用性更好。

阶段 2:引入动态层结构

当层数和每层大小需要运行时配置时,改用 Layer 结构体和 malloc:

typedef struct {
    int input_size, output_size;
    double *weights;   // 展平存储:weights[output_size * input_size]
    double *bias;
    double *z, *a, *delta;
    double (*activation)(double);
    double (*activation_deriv)(double activation_value);
} Layer;

typedef struct {
    Layer *layers;
    int num_layers;
} NeuralNetV3;

注意:

  • 权重从二维数组变成一维展平,访问时用 weights[i * input_size + j] 代替 W[i][j]。
  • 一维存储的好处是便于序列化到文件、也便于后续用 BLAS / GPU 加速。
  • 需要在 init_net 里 malloc,在程序结束时 free,避免内存泄漏。

阶段 3:分离”模型参数”与”计算图状态”

再进一步,可以学习现代框架的Parameter / Tensor思想:

typedef struct {
    double *data;   // 参数值(W 或 b)
    double *grad;   // 梯度
    int size;
} Parameter;

typedef struct {
    Parameter *weights;  // 每层一个权重
    Parameter *biases;   // 每层一个偏置
} Model;

typedef struct {
    double **inputs;  // 每层的输入
    double **z;       // 每层的净输入
    double **a;       // 每层的激活
    double **delta;   // 每层的误差
} Cache;

此时 forward(Model *model, Cache *cache, double *input) 只读模型、写缓存;backward 也只读模型、写缓存,并累加梯度到 Parameter.grad。这样模型和缓存可以独立保存、加载和调试。

阶段 4:增加模块化和功能开关

最后可以加入:

  • 可插拔激活函数(sigmoid / ReLU / tanh / softmax)
  • 可配置损失函数(MSE / 交叉熵)
  • 优化器接口(SGD / Momentum / Adam)
  • 数据加载器(batch、shuffle)
  • 模型保存 / 加载(二进制或 JSON)
  • 推理模式(关闭 dropout、不计算梯度)

这些扩展的关键思想都是:先区分”可学习参数”、”计算状态”、”算法配置”三类数据,再把硬编码的具体值改成可配置的数据结构。本教程中的 NeuralNet 其实已经暗含了这三类:

类型 本代码字段 未来框架对应概念
可学习参数 hidden_weights, hidden_biases, output_weights, output_biases Parameter / nn.Parameter
计算状态 hidden_z, hidden_a, output_z, output_a, hidden_delta, output_delta Tensor / 计算图缓存
算法配置 宏 INPUT_SIZE, HIDDEN_SIZE, OUTPUT_SIZE,学习率等 配置文件 / 超参数对象

理解了这种分层,再读 PyTorch 的 nn.Module、TensorFlow 的 Layer、JAX 的 params 字典就会非常自然。


附录 A:数学公式速查表

概念 公式
神经元净输入 $z = /sum_i w_i x_i + b$
Sigmoid 激活 $a = /sigma(z) = /frac{1}{1+e^{-z}}$
Sigmoid 导数 $/sigma'(z) = /sigma(z)(1-/sigma(z)) = a(1-a)$
损失函数 $L = /frac{1}{2}(/hat{y} – y)^2$
输出层误差项 $/delta_2 = (a_2 – y) /cdot a_2 /cdot (1 – a_2)$
隐藏层误差项 $/delta_{1,i} = /delta_2 /cdot W2_{1,i} /cdot a_{1,i} /cdot (1 – a_{1,i})$
梯度下降更新 $W /leftarrow W – lr /cdot /delta /cdot /text{input}$
偏置更新 $b /leftarrow b – lr /cdot /delta$

附录 B:推荐阅读

  1. 《Neural Networks and Deep Learning》 — Michael Nielsen(免费在线书,适合入门)
  2. 《Deep Learning》 — Ian Goodfellow, Yoshua Bengio, Aaron Courville(”花书”,深度学习圣经)
  3. 3Blue1Brown 的神经网络系列视频 — 视觉化理解反向传播(YouTube)
  4. CS231n: Convolutional Neural Networks for Visual Recognition — Stanford 课程
  5. PyTorch 官方教程 — 从实践入手学习深度学习框架

你还可以尝试修改隐藏层大小、学习率、训练轮次等参数,观察它们对训练结果的影响。

https://segmentfault.com/a/1190000048084308

作者

itmax

关注我
其他文章
上一个

Vibe Coding 时代,程序员该何去何从

下一个

Golang内存逃逸分析实战:从原理到极致性能优化

暂无评论!成为第一个。

发表回复 取消回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Copyright 2026 — IT极限技术分享汇. 备案号:浙ICP备17006560号-2