全连接神经网络的通用实现
Table of content
从专用代码到通用库:全连接神经网络的抽象
1. 手写代码的天花板
回顾一下前几篇走的路:
- 拟合直线时,模型只有两个参数
w和b,手写求梯度毫无压力。 - 训练逻辑门时,单个神经元有三个参数,也还好。
- 到了 XOR,网络有九个参数,
finite_diff里开始出现大量重复代码:每个参数都要写一遍“加上 eps、算 cost、恢复“。
上一篇我们又从数学上证明了:不管网络有多少层、每层有多少神经元,前向传播都是同一个公式:
z^[l] = W^[l] * a^[l-1] + b^[l]
a^[l] = σ(z^[l])数学上已经统一了,代码却还是专用的:Xor 结构体里写死了九个参数名,换一个网络结构就得重写一份。
这篇要做的事情,就是让代码也追赶上数学:写一份通用的网络实现,网络的形状不再写死在结构体里,而是用一个数组描述。比如 {2, 4, 1} 就表示“输入 2 个节点,隐藏层 4 个神经元,输出 1 个神经元“。想换结构?改数组就行,库代码一行不动。
2. 先抽象矩阵
上一篇说过,矩阵运算是神经网络的通用语言。所以第一步是给 C 语言补上一个矩阵结构:
typedef struct {
size_t rows;
size_t cols;
size_t stride;
float* p;
} Matrix;
#define MATRIX_AT(m, i, j) (m).p[(i)*(m).stride + (j)]矩阵的数据存在一块连续的一维数组 p 里,第 i 行第 j 列的元素通过 p[i * stride + j] 访问。
stride 是干什么的?
stride 表示“每行在内存里占多少个位置“。大多数情况下 stride == cols,但它也可以比 cols 大。这个小小的设计带来一个很大的便利:一个矩阵可以是另一块内存的“视图“,而不必复制数据。
后面训练数据会用到这个技巧,先记住它。
基于这个结构,可以实现一组基础操作:
void mat_dot(Matrix dst, Matrix a, Matrix b); // 矩阵乘法
void mat_add(Matrix dst, Matrix a); // 矩阵加法
void mat_sig(Matrix m); // 每个元素过一遍 sigmoid
void mat_copy(Matrix dst, Matrix src); // 复制
Matrix mat_row(Matrix m, size_t row); // 取出某一行,作为新矩阵其中 mat_row 很有意思:它返回的矩阵不拥有自己的数据,只是指向原矩阵某一行的开头。相当于给原矩阵开了一扇“窗户“,通过窗户读写,动的还是原来的数据。
Matrix mat_row(Matrix m, size_t row) {
return (Matrix){
.rows = 1,
.cols = m.cols,
.stride = m.cols,
.p = &MATRIX_AT(m, row, 0),
};
}3. 网络结构体:三个数组
一个全连接网络需要存什么?对照上一篇的公式数一下:
- 每层一个权重矩阵
W^[l] - 每层一个偏置向量
b^[l] - 每层的输出
a^[l](计算下一层时要用)
所以网络结构体就是三个数组:
typedef struct {
size_t count; // 层数(不含输入层)
Matrix* weights; // 每层的权重矩阵
Matrix* biases; // 每层的偏置
Matrix* activations; // 每层的输出,额外多存了最初的输入
} KNN;注意 activations 比另外两个数组多一个元素:activations[0] 存的是网络最初的输入 x,activations[count] 就是最终输出。代码里用两个宏把这两头取出来:
#define KNN_INPUT(n) (n).activations[0]
#define KNN_OUTPUT(n) (n).activations[n.count]按形状分配内存
网络有几层、每层几个神经元,全由一个数组决定:
KNN knn_alloc(size_t *arch, size_t total_count) {
KNN n;
n.count = total_count - 1;
n.weights = _KNN_MALLOC_(sizeof(*n.weights) * n.count);
n.biases = _KNN_MALLOC_(sizeof(*n.biases) * n.count);
n.activations = _KNN_MALLOC_(sizeof(*n.activations) * (n.count + 1));
KNN_INPUT(n) = malloc_matrix(1, arch[0]);
for(size_t i = 1; i < total_count; i += 1) {
n.weights[i - 1] = malloc_matrix(n.activations[i - 1].cols, arch[i]);
n.biases[i - 1] = malloc_matrix(1, arch[i]);
n.activations[i] = malloc_matrix(1, arch[i]);
}
return n;
}对照上一篇“权重矩阵是 n_out × n_in“的结论,这里代码把矩阵写成了 1 × n 的行向量形式,所以权重矩阵的行数是上一层的宽度、列数是当前层的宽度。形状全由 arch 推导出来,写网络的人不用操心维度对不对——维度不对的话,矩阵运算里的断言会直接报错。
对比一下:之前训练 XOR 要专门定义一个九个字段的 Xor 结构体;现在 knn_alloc 加上 {2, 2, 1} 这个数组就够了,换成 {2, 4, 1}、{784, 256, 128, 10} 也同样是一行的事。
4. 前向传播:三行代码
上一篇推导的通用公式,翻译成代码就是三行:
void knn_forward(KNN n) {
for (size_t i = 0; i < n.count; i += 1) {
mat_dot(n.activations[i + 1], n.activations[i], n.weights[i]);
mat_add(n.activations[i + 1], n.biases[i]);
mat_sig(n.activations[i + 1]);
}
}每一层做三件事:矩阵乘法、加偏置、过 sigmoid。结果直接写进 activations[i + 1],作为下一层的输入。循环跑完,输出就躺在 KNN_OUTPUT(n) 里。
这几行代码完全不知道网络是两层还是二十层,这就是矩阵抽象换来的通用性。
5. 训练:cost、梯度、更新
训练的骨架和前面几篇一模一样,只是每个环节都从“手写“变成了“循环“。
cost:遍历训练集
float knn_cost(KNN n, Matrix ti, Matrix to) {
float cost = 0.f;
for(size_t i = 0; i < ti.rows; i += 1) {
Matrix x = mat_row(ti, i);
Matrix y = mat_row(to, i);
mat_copy(KNN_INPUT(n), x);
knn_forward(n);
for(size_t j = 0; j < to.cols; j++) {
float d = MATRIX_AT(KNN_OUTPUT(n), 0, j) - MATRIX_AT(y, 0, j);
cost += d * d;
}
}
return cost / ti.rows;
}ti 是输入矩阵(每行一个样本),to 是期望输出矩阵。逐行取出样本喂给网络,把预测值和期望值的差平方累加起来,最后取平均——就是之前一直在用的均方误差。
这里用到了 mat_row 的“窗户“特性:样本是直接看进训练数据里的,没有复制。
stride 的实际用处
XOR 的训练数据长这样,每一行是 x1, x2, y 三个数挤在一起:
float data[] = {
0, 0, 0,
0, 1, 1,
1, 0, 1,
1, 1, 0
};输入和输出混在同一块内存里,怎么分开?靠 stride:
Matrix ti = {
.rows = rows,
.cols = 2,
.stride = 3, // 每行跨 3 个数,但只看前 2 个
.p = data,
};
Matrix to = {
.rows = rows,
.cols = 1,
.stride = 3, // 每行跨 3 个数,只看第 3 个
.p = data + 2,
};ti 从 data 开头看,每行读 2 个数、跳过 3 个数;to 从 data + 2 开头看,每行读 1 个数。同一块内存,两扇不同的窗户,输入输出就分开了,一个拷贝都不需要。
梯度:通用的有限差分
之前给 XOR 求梯度,九个参数要手写九遍“加 eps、算 cost、恢复“。现在参数都在矩阵里,三层循环搞定:
void knn_finite_diff(KNN n, KNN g, float eps, Matrix ti, Matrix to) {
float saved;
for (size_t i = 0; i < n.count; i += 1) {
float c = knn_cost(n, ti, to);
for (size_t j = 0; j < n.weights[i].rows; j += 1) {
for(size_t k = 0; k < n.weights[i].cols; k += 1) {
saved = MATRIX_AT(n.weights[i], j, k);
MATRIX_AT(n.weights[i], j, k) += eps;
MATRIX_AT(g.weights[i], j, k) = (knn_cost(n, ti, to) - c) / eps;
MATRIX_AT(n.weights[i], j, k) = saved;
}
}
// 偏置同理,循环一遍 n.biases[i]
// ...
}
}g 是一个和 n 形状相同的网络,但不拿来预测,只用来装梯度——每个参数的梯度存在 g 对应的位置上。思路没变:把某个参数稍微抬一下,看 cost 涨了多少,涨得越快说明这个参数越“敏感“。
更新:沿着梯度反方向走一步
void knn_learn(KNN n, KNN g, float rate) {
for(size_t i = 0; i < n.count; i += 1) {
// 权重和偏置都做同一件事:
MATRIX_AT(n.weights[i], j, k) -= rate * MATRIX_AT(g.weights[i], j, k);
// ...
}
}还是那句老话:梯度指向 cost 增长最快的方向,所以反着走。
6. 单头文件:一个小约定
这份库只有一个 knn.h,实现也写在同一个文件里,用宏开关隔开:
#ifdef __KNN_IMPLEMENTATION__
// 所有函数的实现
#endif使用的时候,在一个 .c 文件里先定义宏再包含:
#define __KNN_IMPLEMENTATION__
#include "knn.h"其他地方正常 #include "knn.h" 就只拿到声明。这样在 C 这种没有包管理器的语言里,库就是一个文件,拷走即用。
文件开头还有两个可以覆盖的宏:_KNN_MALLOC_ 和 _KNN_ASSERT_。默认分别是 malloc 和 assert,但使用方可以在包含之前定义成自己的版本,比如换成自己的内存分配器。不改库代码,就能改库的行为。
7. 跑起来:还是那个 XOR
把上面所有零件拼起来,训练 XOR 的完整程序短得出乎意料:
int main(){
srand(time(0));
size_t arch[] = {2, 4, 1}; // 网络形状:2 输入,4 隐藏,1 输出
KNN n = knn_alloc(arch, ARR_LEN(arch));
KNN g = knn_alloc(arch, ARR_LEN(arch));
knn_rand(n);
// ti、to 用 stride 从 data 里开窗,见上文
// ...
float eps = 1e-1;
float rate = 1e-1;
for(size_t i = 0; i < 100*1000; i += 1) {
knn_finite_diff(n, g, eps, ti, to);
knn_learn(n, g, rate);
}
verify(n);
return 0;
}注意这里隐藏层用了 4 个神经元,比之前手写的 2 个还多——但代码反而更短了。这就是通用抽象的意义:网络变复杂,代码不变复杂。
训练结果:
0 ^ 0 = 0.018523
0 ^ 1 = 0.982424
1 ^ 0 = 0.982253
1 ^ 1 = 0.016884XOR 再次被学会,这次用的是一份和具体问题完全无关的通用代码。
8. 总结
这篇做了一次“代码追赶数学“的重构:
- 矩阵结构体:一块连续内存加上
rows、cols、stride,配上mat_row这种“开窗户“的视图操作,零拷贝地切分训练数据。 - 网络结构体:权重、偏置、激活值各一个数组,网络形状由
arch数组描述,改结构不用改代码。 - 通用训练循环:前向传播、cost、有限差分求梯度、梯度下降,全部对任意层数、任意宽度成立。
训练的核心思想从第一篇到现在一个字都没变过:
定义模型,计算误差,求梯度,沿着梯度反方向调整参数。
变化的只是我们表达模型的方式:从两个浮点数,到九个字段的结构体,再到三个数组加一个形状描述。
还有一个明显的遗留问题:有限差分求梯度,每算一个参数的梯度都要完整跑一遍 cost。XOR 只有十几个参数,跑得动;上一篇算过,一个像样点的网络有二十多万个参数,这个方法就完全不可行了。下一篇该轮到反向传播登场——用链式法则一次前向、一次反向就拿到所有参数的梯度,这也是上一篇结尾提到的矩阵形式反向传播该落地的时候了。