神经网络入门:从 XOR 问题理解前馈神经网络
本文档基于XOR问题,循序渐进地讲解神经网络的基本原理、数学推导和代码实现。
适合有一定编程基础,但初次接触神经网络的程序员阅读。
目录
- 神经网络是什么?
- XOR 问题:为什么需要神经网络?
-
- 3.1 层 (Layer) 的概念
- 3.2 为什么隐藏层需要 4 个神经元?
- 3.3 网络参数数量
- 3.4 NeuralNet 结构体的设计意图
- 3.5 维度为什么这么定?
- 3.6 静态数组的取舍
- 3.7 通用层抽象
- 神经元内部:加权求和与激活函数
- 正向传播:信号如何流动
- 损失函数:如何衡量对错
- 反向传播:误差如何逆流
- 梯度下降:如何调整参数
- 训练循环:重复的力量
- 代码逐段精读
- 运行与解读结果
-
- 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 的输入,这样可以统一写成一次矩阵乘法。这种技巧确实在数学推导中很常见,但在入门代码里:
- 分开存储更直观:权重 $W$ 负责”输入对输出的影响”,偏置 $b$ 负责”基线激活水平”,两者职责清晰。
- 代码更对称:每个神经元都有
W[i][*]和b[i],公式 $/sum W x + b$ 可以直接对应两行代码。 - 避免输入造假样本:不需要把训练数据扩展成
{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 动态分配。原因:
- 简单、可预测:对于固定层大小的 XOR 网络,编译时就知道所有内存需求,无需处理内存泄漏。
- 缓存友好:结构体在栈或全局区连续分配,访问速度通常优于分散的堆指针。
- 教学清晰:读者不需要分心学习内存管理,能专注于神经网络算法本身。
但静态数组的代价是缺乏灵活性:
- 如果你想把隐藏层改成 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 ── 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 直观理解
正向传播就是输入信号在网络中逐层传递的过程。每一层都:
- 接收上一层的输出
- 用当前层的权重进行加权求和
- 加上偏置
- 通过激活函数压缩
- 传递给下一层
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 梯度$$
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 轮训练。实际中通常使用以下策略:
- Early Stopping:当损失不再明显下降时停止
- 达到目标精度:当验证集精度达到阈值时停止
- 最大轮次限制:设置一个上限,防止无限训练
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:推荐阅读
- 《Neural Networks and Deep Learning》 — Michael Nielsen(免费在线书,适合入门)
- 《Deep Learning》 — Ian Goodfellow, Yoshua Bengio, Aaron Courville(”花书”,深度学习圣经)
- 3Blue1Brown 的神经网络系列视频 — 视觉化理解反向传播(YouTube)
- CS231n: Convolutional Neural Networks for Visual Recognition — Stanford 课程
- PyTorch 官方教程 — 从实践入手学习深度学习框架
你还可以尝试修改隐藏层大小、学习率、训练轮次等参数,观察它们对训练结果的影响。