从零实现的神经网络
Simon_He_HDC · · 科技·工程
观察到 https://www.luogu.com.cn/article/3qk7k9hy 并未给出代码,于是我们尝试根据它和一些高等知识写一个简单的神经网络。我们将只使用 g++ 自带的库。(也就是说你可以在正赛考场上写它)
由于笔者很菜,如有错误,欢迎指出。
本文未使用任何生成式 AI 辅助。
我们的向量将会在行向量和列向量之间反复横跳,但这似乎并不关键。
数学部分
我们明确一下形式,假设神经网络有
记相邻两层之间的转移系数为
记第
记第
设激活函数为
本文使用
正向传播
正向传播是根据输入的值算出结果的过程。第
反向传播
由于笔者并不知道自动求导为何物,但是我们会手动求导:
假设已知损失函数对下一层所有输出值的梯度(的所有分量)
我们需要求出
我们先求出
然后猜测三个式子:
感性理解是对的。写成矩阵和向量形式的化,第一个式子就是矩阵乘向量,第二个是对应位置赋值,第三个是张量积。
然后初始我们需要
训练
随便搞一些样本,每个样本都跑一遍正向传播和反向传播,将梯度累加求和,然后向梯度的反方向调整参数。这个过程重复
如果初始参数都是一个数的话,一层的所有节点将会有相同的导数,导致值总是相同。因此初始需要随机赋值。随机数的值域可以从网上抄一些看起来比较优的。
由于我不想反复调参,所以我们训练的学习率由梯度各个分量归一化得到:
_Tp mx = _Tp();
for(size_t i = 0; i < n - 1; i++) {
for(size_t j = 0; j < l[i + 1]; j++) mx = max(mx, abs(train.db[i][j]));
for(size_t j = 0; j < l[i]; j++)
for(size_t k = 0; k < l[i + 1]; k++) mx = max(mx, abs(train.dw[i][j][k]));
}
// _Tp K = train.alpha;
mx = max(0.02, mx);
_Tp K = train.alpha / mx; // K 才是真正的学习率,此处 alpha 取 1
代码部分
(文末有完整代码)
好了终于可以开始写代码了。首先把所有要用的头文件搞过来,然后生成随机数的 gen 和平方 sqr 可以自己写一个,后面的代码形式会好看一点?
#pragma once
/*
define NDEBUG to disable "assert"
*/
#include<iostream>
#include<fstream>
#include<cstdio>
#include<algorithm>
#include<vector>
#include<cmath>
#include<random>
#include<cassert>
#include<ctime>
#include<iomanip>
using namespace std;
const double eps = 1e-6;
mt19937 rng((random_device){}());
inline double gen(double l, double r) {
return (double)rng() / (double)0xffffffff * (r - l) + l;
}
template<typename _Tp>
inline _Tp sqr(_Tp x) { return x * x; }
然后我们需要实现一个低效且勾史的矩阵向量库,为了方便动态的调整每层的宽度,我们还需要手写内存管理,基本上就是在构造函数里面申请内存,在移动构造函数里面直接把对方的指针搞过来,没什么脑子:
#include "conf.h"
template<typename _Tp>
class matrix {
public:
size_t n, m;
size_t size;
_Tp *data;
inline _Tp *operator[](size_t index) { return data + (index * m); }
inline const _Tp *operator[](size_t index) const { return data + (index * m); }
inline matrix() : n(), m(), size(), data() {}
inline matrix(size_t _n, size_t _m) : n(_n), m(_m), size(_n * _m) { data = new _Tp[size](); }
inline matrix(matrix<_Tp> &&val) : n(val.n), m(val.m), size(val.size) { data = val.data; val.data = NULL; }
inline matrix(const matrix<_Tp> &val) : n(val.n), m(val.m), size(val.size) { data = new _Tp[size](); for(size_t i = 0; i < size; i++) data[i] = val.data[i]; }
inline ~matrix() { if(data != NULL) delete[] data; }
inline void resize(size_t _n, size_t _m) { if(data != NULL) delete[] data; n = _n, m = _m, size = n * m; data = new _Tp[size](); }
inline void clear() { if(data == NULL) data = new _Tp[size](); else for(size_t i = 0; i < size; i++) data[i] = _Tp(); }
inline matrix<_Tp> &operator=(matrix<_Tp> &&val) { assert(n == val.n && m == val.m); if(data != NULL) delete[] data; data = val.data; val.data = NULL; return *this; }
inline matrix<_Tp> &operator=(const matrix<_Tp> &val) { assert(n == val.n && m == val.m); if(data == NULL) data = new _Tp[size](); for(size_t i = 0; i < size; i++) data[i] = val.data[i]; return *this; }
};
template<typename _Tp>
class vec {
public:
size_t n;
_Tp *data;
inline _Tp &operator[](size_t index) { return data[index]; }
inline const _Tp &operator[](size_t index) const { return data[index]; }
inline vec() : n(), data() {}
inline vec(size_t _n) : n(_n) { data = new _Tp[n](); }
inline vec(vec<_Tp> &&val) : n(val.n) { data = val.data; val.data = NULL; }
inline vec(const vec<_Tp> &val) : n(val.n) { data = new _Tp[n](); for(size_t i = 0; i < n; i++) data[i] = val.data[i]; }
inline ~vec() { if(data != NULL) delete[] data; }
inline void resize(size_t _n) { if(data != NULL) delete[] data; n = _n; data = new _Tp[n](); }
inline void clear() { if(data == NULL) data = new _Tp[n](); else for(size_t i = 0; i < n; i++) data[i] = _Tp(); }
inline vec<_Tp> &operator=(vec<_Tp> &&val) { assert(n == val.n); if(data != NULL) delete[] data; data = val.data; val.data = NULL; return *this; }
inline vec<_Tp> &operator=(const vec<_Tp> &val) { assert(n == val.n); if(data == NULL) data = new _Tp[n](); for(size_t i = 0; i < n; i++) data[i] = val.data[i]; return *this; }
};
然后就是实现各种运算,包括矩阵和向量的对应位置相加,相减,数乘,矩阵乘向量,向量乘矩阵,向量的对应位置相乘(elementwise_product)和张量积(tensor_product)。还有重载到 cout 方便我们输出调试。
由于我比较害怕写挂,加了一堆 assert 来判断两个操作数的大小是否匹配,指针是否不为空。这个 assert 占了很多代码。如果觉得慢可以在代码开头加一行
#define NDEBUG
代码如下:
template<typename _Tp>
class matrix {
...
friend inline matrix<_Tp> operator+(const matrix<_Tp> &a, const matrix<_Tp> &b) {
matrix<_Tp> res(a.n, a.m); assert(a.n == b.n && a.m == b.m && a.data && b.data);
for(size_t i = 0; i < a.size; i++) res.data[i] = a.data[i] + b.data[i];
return res;
}
friend inline matrix<_Tp> operator-(const matrix<_Tp> &a, const matrix<_Tp> &b) {
matrix<_Tp> res(a.n, a.m); assert(a.n == b.n && a.m == b.m && a.data && b.data);
for(size_t i = 0; i < a.size; i++) res.data[i] = a.data[i] - b.data[i];
return res;
}
friend inline matrix<_Tp> operator-(const matrix<_Tp> &a) {
matrix<_Tp> res(a.n, a.m); assert(a.data);
for(size_t i = 0; i < a.size; i++) res.data[i] = -a.data[i];
return res;
}
friend inline matrix<_Tp> operator*(const matrix<_Tp> &a, const matrix<_Tp> &b) {
matrix<_Tp> res(a.n, b.m); assert(a.m == b.n && a.data && b.data);
for(size_t k = 0; k < a.m; k++) {
for(size_t i = 0; i < a.n; i++) {
for(size_t j = 0; j < b.m; j++) {
res[i][j] += a[i][k] * b[k][j];
}
}
}
return res;
}
friend inline matrix<_Tp> operator*(const matrix<_Tp> &a, _Tp b) {
matrix<_Tp> res(a.n, a.m); assert(a.data);
for(size_t i = 0; i < a.size; i++) res.data[i] = a.data[i] * b;
return res;
}
inline matrix<_Tp> &operator+=(const matrix<_Tp> &b) {
assert(n == b.n && m == b.m && data && b.data);
for(size_t i = 0; i < size; i++) data[i] += b.data[i];
return *this;
}
};
template<typename _Tp>
class vec {
public:
...
inline friend vec<_Tp> operator+(const vec<_Tp> &a, const vec<_Tp> &b) {
vec<_Tp> res(a.n); assert(a.n == b.n && a.data && b.data);
for(size_t i = 0; i < a.n; i++) res[i] = a[i] + b[i];
return res;
}
inline friend vec<_Tp> operator-(const vec<_Tp> &a, const vec<_Tp> &b) {
vec<_Tp> res(a.n); assert(a.n == b.n && a.data && b.data);
for(size_t i = 0; i < a.n; i++) res[i] = a[i] - b[i];
return res;
}
inline friend vec<_Tp> operator-(const vec<_Tp> &a) {
vec<_Tp> res(a.n); assert(a.data);
for(size_t i = 0; i < a.n; i++) res[i] = -a[i];
return res;
}
inline friend vec<_Tp> operator*(const vec<_Tp> &a, const matrix<_Tp> &b) {
vec<_Tp> res(b.m); assert(a.n == b.n && a.data && b.data);
for(size_t i = 0; i < a.n; i++) {
for(size_t j = 0; j < b.m; j++) {
res[j] += a[i] * b[i][j];
}
}
return res;
}
inline friend vec<_Tp> operator*(const matrix<_Tp> &a, const vec<_Tp> &b) {
vec<_Tp> res(a.n); assert(a.m == b.n && a.data && b.data);
for(size_t i = 0; i < a.n; i++) {
for(size_t j = 0; j < a.m; j++) {
res[i] += a[i][j] * b[j];
}
}
return res;
}
inline friend vec<_Tp> operator*(const vec<_Tp> &a, _Tp b) {
vec<_Tp> res(a.n); assert(a.data);
for(size_t i = 0; i < a.n; i++) res[i] = a[i] * b;
return res;
}
inline friend vec<_Tp> elementwise_product(const vec<_Tp> &a, const vec<_Tp> &b) {
vec<_Tp> res(a.n); assert(a.n == b.n && a.data && b.data);
for(size_t i = 0; i < a.n; i++) res[i] = a[i] * b[i];
return res;
}
inline friend matrix<_Tp> tensor_product(const vec<_Tp> &a, const vec<_Tp> &b) {
matrix<_Tp> res(a.n, b.n);
for(size_t i = 0; i < a.n; i++) {
for(size_t j = 0; j < b.n; j++) {
res[i][j] = a[i] * b[j];
}
}
return res;
}
inline vec<_Tp> &operator+=(const vec<_Tp> &b) {
assert(n == b.n && data && b.data);
for(size_t i = 0; i < n; i++) data[i] += b[i];
return *this;
}
inline vec<_Tp> apply(_Tp (*func)(_Tp)) const {
vec<_Tp> res(n); assert(data != NULL);
for(size_t i = 0; i < n; i++) res[i] = func(data[i]);
return res;
}
};
template<typename _Tp>
inline ostream &operator<<(ostream &out, const matrix<_Tp> &val) {
out << "mat" << "=\n";
for(size_t i = 0; i < val.n; i++) {
out << "[";
for(size_t j = 0; j < val.m; j++) {
out << val[i][j];
if(j != val.m - 1) cout << ", ";
}
out << "]\n";
}
return out;
}
template<typename _Tp>
inline ostream &operator<<(ostream &out, const vec<_Tp> &val) {
out << "vec" << "=[";
for(size_t i = 0; i < val.n; i++) {
out << val[i];
if(i != val.n - 1) cout << ", ";
}
out << "]\n";
return out;
}
好的以上这些相信大家自己都能写,没什么好说的,就是注意内存管理的问题。然后由于我们封装的比较完善,神经网络内部的实现就相对简单,只需要把式子抄一遍就行。
由于我们多个样本对平均梯度的贡献是需要累加的,所以我将神经网络的类 nnet 和累计梯度的类 trainTp 分开写了。本来是为了后面写随机化做铺垫,但是直接跑梯度下降效果就很好了,写法保留了。
下面先是内存管理,就是在构造函数里面把每层宽度 l 搞过来,然后对着 l 调用矩阵和向量的 resize 申请内存。
#include "conf.h"
#include "matrix.h"
template<typename _Tp>
class trainTp {
public:
size_t n;
size_t *l;
vec<_Tp> *r; // 正向传播得到的结果
vec<_Tp> *r1; // 激活之前的值
vec<_Tp> *dr; // dloss/dr 梯度
vec<_Tp> *dr1; // dloss/dr1 梯度
size_t cnt; // 学习次数,因为我们是自适应学习率,所以没用
matrix<_Tp> *dw; // dloss/dw 累计梯度
vec<_Tp> *db; // dloss/db 累计梯度
_Tp alpha; // 神秘常数
inline trainTp(size_t _n, size_t *_l) : n(_n) {
l = _l;
r = new vec<_Tp>[n]();
r1 = new vec<_Tp>[n]();
dr = new vec<_Tp>[n]();
dr1 = new vec<_Tp>[n]();
dw = new matrix<_Tp>[n - 1]();
db = new vec<_Tp>[n - 1]();
for(size_t i = 0; i < n - 1; i++) {
dw[i].resize(l[i], l[i + 1]);
db[i].resize(l[i + 1]);
}
for(size_t i = 0; i < n; i++) {
r[i].resize(l[i]);
r1[i].resize(l[i]);
dr[i].resize(l[i]);
dr1[i].resize(l[i]);
}
}
inline ~trainTp() {
delete[] r;
delete[] r1;
delete[] dr;
delete[] dr1;
delete[] dw;
delete[] db;
}
inline void setAlpha(_Tp _alpha) { alpha = _alpha; }
inline void clearSum() {
for(size_t i = 0; i < n - 1; i++) {
dw[i].clear();
db[i].clear();
}
}
};
template<typename _Tp>
class nnet {
public:
size_t n; // 层数
size_t *l; // 每层宽度
matrix<_Tp> *w; // 贡献系数
vec<_Tp> *b; // 偏置
_Tp (*f)(_Tp); // 激活函数
_Tp (*df)(_Tp); // df/dx
inline nnet(size_t _n, size_t *_l, _Tp (*_f)(_Tp), _Tp (*_df)(_Tp)) : n(_n) {
f = _f, df = _df; l = _l;
w = new matrix<_Tp>[n - 1]();
b = new vec<_Tp>[n - 1]();
for(size_t i = 0; i < n - 1; i++) {
w[i].resize(l[i], l[i + 1]);
b[i].resize(l[i + 1]);
}
}
inline ~nnet() {
delete[] w;
delete[] b;
}
};
为了便于统计训练效果,我们封装一个统计类,支持插入,求 max, min, avg, sigma, sum:
template<typename _Tp>
class resTp {
private:
vector<_Tp> data;
public:
inline void clear() { data.clear(); }
// non-negative numbers only
inline void insert(_Tp val) { data.push_back(val); }
inline size_t size() { return data.size(); }
inline _Tp sum() { _Tp res = 0; for(_Tp i : data) res += i; return res; }
// 平方和
inline _Tp sum2() { _Tp res = 0; for(_Tp i : data) res += i * i; return res; }
inline _Tp Max() { _Tp res = -1; for(_Tp i : data) if(res < i) res = i; return res; }
inline _Tp Min() { if(data.empty()) return -1; _Tp res = data.front(); for(_Tp i : data) if(i < res) res = i; return res; }
inline _Tp Mid() { if(data.empty()) return -1; nth_element(data.begin(), data.begin() + data.size() / 2, data.end()); return data[data.size() / 2]; }
inline _Tp avg() { if(data.empty()) return -1; return sum() / size(); }
// 标准差
inline _Tp sigma() { return sqrt(sum2() / size() - sqr(avg())); }
};
好的接下来可以写正向传播和反向传播了,还有训练和随机赋初值。直接对着数学部分的式子写一遍就行。
template<typename _Tp>
class nnet {
public:
size_t n; // 层数
size_t *l; // 每层宽度
matrix<_Tp> *w; // 贡献系数
vec<_Tp> *b; // 偏置
_Tp (*f)(_Tp); // 激活函数
_Tp (*df)(_Tp); // df/dx
...
inline void forward(trainTp<_Tp> &train, const vec<_Tp> &start) {
assert(start.n == l[0]);
train.r[0] = start;
for(size_t i = 0; i < n - 1; i++) {
train.r1[i + 1] = train.r[i] * w[i] + b[i];
train.r[i + 1] = train.r1[i + 1].apply(f);
}
}
inline void backward(trainTp<_Tp> &train, const vec<_Tp> &ed) {
train.cnt++;
assert(ed.n == l[n - 1]);
train.dr[n - 1] = (train.r[n - 1] - ed) * 2;
for(size_t i = n - 2; i != (size_t)(-1); i--) {
train.dr1[i + 1] = elementwise_product(train.dr[i + 1], train.r1[i + 1].apply(df));
train.dr[i] = w[i] * train.dr1[i + 1];
train.db[i] += train.dr1[i + 1];
train.dw[i] += tensor_product(train.r[i], train.dr1[i + 1]);
}
}
inline void train(trainTp<_Tp> &train, bool debug = false) {
_Tp mx = _Tp();
for(size_t i = 0; i < n - 1; i++) {
for(size_t j = 0; j < l[i + 1]; j++) mx = max(mx, abs(train.db[i][j]));
for(size_t j = 0; j < l[i]; j++)
for(size_t k = 0; k < l[i + 1]; k++) mx = max(mx, abs(train.dw[i][j][k]));
}
// _Tp K = train.alpha;
mx = max(0.02, mx);
_Tp K = train.alpha / mx;
if(debug) cout << "最终梯度:\n";
for(size_t i = 0; i < n - 1; i++) {
if(debug) cout << train.db[i] * K << train.dw[i] * K << endl;
b[i] += -train.db[i] * K;
w[i] += -train.dw[i] * K;
}
}
// 随机赋初值,值域是从网上抄的,好像有什么意义
inline void random() {
for(size_t i = 0; i < n - 1; i++) {
double val = sqrt(6.0 / (l[i] + l[i + 1]));
for(size_t j = 0; j < l[i + 1]; j++) b[i][j] = gen(-val, val);
for(size_t j = 0; j < l[i]; j++)
for(size_t k = 0; k < l[i + 1]; k++) w[i][j][k] = gen(-val, val);
}
}
};
然后写一个 summary 函数用来计算一个样本的 loss。由于神经网络的计算结果就是存在 trainTp 里的,所以直接传一个 trainTp 和标准答案 ed 就行。
inline void summary(trainTp<_Tp> &train, const vec<_Tp> &ed, resTp<_Tp> &res) {
_Tp sum = _Tp();
for(size_t i = 0; i < l[n - 1]; i++) sum += sqr(train.r[n - 1][i] - ed[i]);
res.insert(sum);
}
这样就都封装好了,我们只需要在主函数里面设置层数、宽度、激活函数。然后读取数据集,并调用训练函数就可以了。
#include "conf.h"
#include "matrix.h"
#include "net.h"
using namespace std;
double f(double x) { return tanh(x); }
double df(double x) { return 1 - sqr(tanh(x)); }
vec<double> bg[501];
vec<double> ed[501];
int main() {
cout << fixed << setprecision(4);
ifstream fin("dataset.in");
size_t n = 5;
size_t l[5] = {1, 4, 4, 4, 3};
nnet<double> net(n, l, f, df);
trainTp<double> tr(n, l);
tr.setAlpha(1);
net.random();
cout << "初始参数:\n";
for(size_t i = 0; i < n - 1; i++) {
cout << net.b[i] << net.w[i] << endl;
}
cout << "开始训练\n";
for(int i = 0; i < 500; i++) {
bg[i].resize(l[0]), ed[i].resize(l[n - 1]);
for(int j = 0; j < l[0]; j++) fin >> bg[i][j];
for(int j = 0; j < l[n - 1]; j++) fin >> ed[i][j];
}
for(int cnt = 1; cnt <= 2000; cnt++) {
if(cnt % 100 == 0) cout << "cnt: " << cnt << endl;
tr.clearSum();
for(int i = 0; i < 500; i++) {
net.forward(tr, bg[i]);
net.backward(tr, ed[i]);
}
net.train(tr, (cnt == 2000));
}
cout << "最终参数:\n";
for(size_t i = 0; i < n - 1; i++) {
cout << net.b[i] << net.w[i] << endl;
}
tr.clearSum();
net.forward(tr, bg[0]);
cout << "中间结果:\n";
for(size_t i = 0; i < n; i++) {
cout << tr.r[i];
}
resTp<double> res;
bg[500].resize(l[0]), ed[500].resize(l[n - 1]);
for(int i = 0; i < 500; i++) {
for(int j = 0; j < l[0]; j++) fin >> bg[500][j];
for(int j = 0; j < l[n - 1]; j++) fin >> ed[500][j];
net.forward(tr, bg[500]);
net.summary(tr, ed[500], res);
}
cout << "测试数据集: \tMax\tMin\tMid\tAvg\tsigma\t\n";
cout << " \t" << res.Max() << '\t' << res.Min() << '\t' << res.Mid() << '\t' << res.avg() << '\t' << res.sigma() << '\n';
double te;
while(cin >> te) {
bg[500][0] = te;
net.forward(tr, bg[500]);
for(int j = 0; j < l[n - 1]; j++) cout << tr.r[n - 1][j] << ' '; cout << '\n';
}
return 0;
}
然后如果要训练的话,我们写个 generator,训练神经网络判断一个数是在
#include<iostream>
#include<random>
using namespace std;
mt19937 rng((random_device){}());
int gen(int l, int r) { return (int)(rng() % (r - l + 1) + l); }
int n = 20;
int f(bool x) { return x ? 1 : -1; }
int main() {
for(int i = 1; i <= 1000; i++) {
double t = (double)gen(-100, 100) / 100;
cout << t << ' ';
cout << f(t < 0) << ' ';
cout << f(0 <= t && t <= 0.5) << ' ';
cout << f(0.5 < t && t <= 1) << '\n';
}
return 0;
}
在本地编译运行,解决这种简单的问题可以轻松让 O2 将会大幅提高训练速度。
另外本文还没有加入 GPU 和多线程、指令集的优化,以后可能会加。
你学会了吗
完整代码
练习
用上面的一坨通过 B3684 [语言月赛 202212] 不可以,总司令。
在 基于日线的 A 股量化交易模拟 中获得 1000 万分(bushi)。