C++ 重写神经网络框架之autograd(1)

张开发
2026/4/16 3:33:48 15 分钟阅读

分享文章

C++ 重写神经网络框架之autograd(1)
作为一个热爱C的中学生OIer, 非常想学神经网络, 可是不想用python, 怎么办ne? (本人最终还是学了Py磨TensorFlow/Pytorch)功能不重要, 热爱最重要, 原理的美感最重要于是, 本蒟蒻想写一个简单的TinyDNN, 练习一下 (第一篇博客)话不多说, 直接开始神经网络, 具体是什么以后再说, 先知道, 我最重要的就是一个线性代数库, 和一个Autograd机制,这篇博客, 教会大家用非工业的代码写一个AutoGrad [自动求导]比较深的数学原理请自行查阅自动求导类定义训练大的神经网络的时候, 对于精度有很多选择如float32/16/...但是这里我们用于学习, 直接使用double先导入本章要用的包:#include iostream #include algorithm #include vector #include format #include unordered_map #include cstring #include cassert #include stdexcept #include functional #include random #include ctime #include cmath using namespace std;具体架构, 我们把数据和功能分开, 数据类叫BaseDouble, 没有实际计算的函数实际计算的class 叫 vdouble具体BaseDouble都有哪些呢:字段功能typev储存计算后的实际值doublegrad计算的梯度(偏导数)doubleid为了省去没用的垃圾回收和管理变量的生存周期, 直接才用string 进行标识每个BaseDoublestringfa1父亲1的id, 用于自动求导, 比如 A B C: C的fa1就是A.idstringfa2父亲2的id, 用于自动求导, 比如 A B C: C的fa2就是B.idstringback_fn用于记录如何反向传播, 等会详细讲解funcitonvoid(string, string, double, double)加上用于调试的重载cout, 总体代码:struct BaseDouble { BaseDouble(string name , double _value 0.0, double _grad 0.0, string _fa1 , string _fa2 ) : id(name), v(_value), grad(_grad), fa1(_fa1), fa2(_fa2), back_fn(nullptr) { } double v, grad; string id, fa1, fa2; function void(string, string, double, double) back_fn; friend ostream operator (ostream out, BaseDouble x) { outformat(BaseDouble{}(value{:.4f}, grad{:.4f}, fa[{}, {}]), x.id, x.v, x.grad, x.fa1, x.fa2)\n; return out; } friend ostream operator (ostream out, const BaseDouble x) { outformat(BaseDouble{}(value{:.4f}, grad{:.4f}, fa[{}, {}]), x.id, x.v, x.grad, x.fa1, x.fa2)\n; return out; } };这里使用了C20的 format中的format函数 (我Python的f-string用多了)简单例子: format({:.2f}, 3.1415) 返回一个string 3.14 [非常好用]其中back_fn没有使用C的函数指针function void(string, string, double, double)表示一个函数接受args[string, string, double, double] 返回void前文提到使用string来进行表示BaseDouble就是为了让标准库帮我们管理unordered_map string, BaseDouble pool; mt19937 mt(time(0)); unordered_map string, size_t opt_cnt; string auto_gen_name(string name1, string name2, string opt, int special_pid) { return format({}:{}:{}|{}, name1, opt, name2, to_string(special_pid)); }这里opt_cnt是用来表示exp/log等单目运算的返回值标识, 看后文代码就明白了auto_gen_name用于生成A B的返回值C的id开始写vdouble !先写一个框架class vdouble { public: vdouble(string name , double val 0.0, string _fa1 , string _fa2 ) : id(name) { pool[name] BaseDouble(name, val, 0, _fa1, _fa2); } BaseDouble val() { if(id ) { throw runtime_error(vdouble.val: you must set id before using vdouble); } return pool[id]; } double item() const { if(id ) { throw runtime_error(vdouble.item: you must set id before using vdouble); } return pool[id].v; } string get_id() const { return id; } private: string id; }; void show() { coutshow values in pool\n; for(auto [id, val] : pool) { coutval\n; } }接下来详细讲解加法运算Autograd本质上是在求解一个DAG (有向无环图)比如 D A * B C先进行前向传播(可能是我自己起的名字, 不过名字不重要),就是tmp A B ans C tmp进行纯正口味的简单计算反向传播就是根据操作函数f 比如mul (乘法), add(加法), 的偏导数通过已知ans.grad1 (因为任何对自己的导数都是1), ans.val我知道进行自动求导比如在加法中c a b:∂y/∂a 1∂y/∂b 1那么backward(反向传播的酷炫写法)公式:a.grad c.grad * 1b.grad c.grad * 1就是这么简单(我可不是说数学原理简单超级复杂的函数的导数我直接背下来[还总忘QAQ])那么, 加法的实现:public: friend vdouble operator (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, , opt_cnt[]), pool[x.id].v pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad; pool[_fa2].grad ngrad; }; return ret; }这里是直接使用vdouble的构造函数填写基本的fa1, fa2, v(这里就是xy的值), 和新的id之所以使用opt_cnt是因为, 这个简单的库是基于unordered_map(哈希O(1)), 如果名字一样会导致崩溃, 所以像这种b exp(a) 的运算, 只有fa1的名字,没有fa2的, 就会导致第二个c exp(a)中c.id b.id就炸了, 所以加入opt_cnt 可以让这个 id 永远不重复(使用了size_t ^_^)这段代码还使用了lambda表达式, 这里简单的教一下:f [](函数的参数) - 返回类型(不填写会自动推导) { 你想运行的东西 return ...; };[]表示引用捕获, 就是说我的lambda内部可以使用外面的所有变量, 并且可以修改, by the way, []是只能读取不能修改的意思要想使用这个f, 我们可以直接f(函数的参数)tips: ngrad 是当前梯度(ret的), val是当前的值(ret的) 等会会用到加法大功告成 添加其他二元运算我们写过, 其他的我们知道公式就行了减法 y a - ba.grad ngrad; b.grad - ngrad;乘法 y a * ba.grad ngrad * b.val; b.grad ngrad * a.val;除法 y a / ba.grad ngrad * (1.0 / b.val); b.grad ngrad * (-a.val) / (b.val * b.val);大家可以自己试着写一下, 我是这样写的:public: friend vdouble operator (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, , opt_cnt[]), pool[x.id].v pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad; pool[_fa2].grad ngrad; }; return ret; } friend vdouble operator - (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, -, opt_cnt[-]), pool[x.id].v - pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad; pool[_fa2].grad - ngrad; }; return ret; } friend vdouble operator * (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, *, opt_cnt[*]), pool[x.id].v * pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * pool[_fa2].v; pool[_fa2].grad ngrad * pool[_fa1].v; }; return ret; } friend vdouble operator / (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, /, opt_cnt[/]), pool[x.id].v / pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { auto a pool[_fa1].v; auto b pool[_fa2].v; pool[_fa1].grad ngrad * (1.0 / b); pool[_fa2].grad ngrad * (-a) / (b * b); }; return ret; }接下来写单目运算还是拿exp举例, y exp(x)公式: x.grad ngrad * y.val;因为没有fa2 (因为只有一个标量参加运算), 我么直接填写None,public: friend vdouble exp(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, exp, opt_cnt[exp]), std::exp(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * val; }; return ret; }常用的cos/sin/log 和一个常用的激活函数relu(就是非线性函数, 以后再说, 现在知道y relu(x) x 0 ? x : 0 就行)y sin(x): x.grad ngrad * cos(x_val);y cos(x): x.grad ngrad * (-sin(x_val));y log(x): x.grad ngrad * (1.0 / x_val);y relu(x) max(x, 0): x.grad (x_val 0) ? ngrad : 0.0tips: 这里的log是以e为底代码:public: friend vdouble exp(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, exp, opt_cnt[exp]), std::exp(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * val; }; return ret; } friend vdouble sin(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, sin, opt_cnt[sin]), std::sin(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * std::cos(pool[_fa1].grad); }; return ret; } friend vdouble cos(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, cos, opt_cnt[cos]), std::cos(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * (-std::sin(pool[_fa1].grad)); }; return ret; } friend vdouble log(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, log, opt_cnt[log]), std::log(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * (1.0 / val); }; return ret; } friend vdouble relu(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, relu, opt_cnt[relu]), std::max(0.0, pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad (pool[_fa1].v 0 ? ngrad : 0.0); }; return ret; }大功告成!关键:backward递归backward就是一个普通的DAG递归, OIer不用教,这里顺便把沿着求导路径的grad置为0的函数zero_grad加上了private: void _backward(string now) { if(pool[now].fa1 pool[now].fa2 ) { return ; } pool[now].back_fn(pool[now].fa1, pool[now].fa2, pool[now].grad, pool[now].v); if(pool[now].fa1 ! ) _backward(pool[now].fa1); if(pool[now].fa2 ! ) _backward(pool[now].fa2); } void _zero_grad(string now) { pool[now].grad 0.0; if(pool[now].fa1 ! ) _zero_grad(pool[now].fa1); if(pool[now].fa2 ! ) _zero_grad(pool[now].fa2); }_backward是内核递归函数, 就是先执行now的back_fn计算梯度, 判断两个父亲在不在, 如果在就递归(反正没有环)_zero_grad差不多接下来在接口里面添加public: void backward() { pool[id].grad 1; _backward(id); } void zero_grad() { _zero_grad(id); }我之前说过id.grad 1 的原理, 这里就不说了大大大大工工工工告告告告成成成成测试完整代码:#include iostream #include algorithm #include vector #include format #include unordered_map #include cstring #include cassert #include stdexcept #include functional #include random #include ctime #include cmath using namespace std; struct BaseDouble { BaseDouble(string name , double _value 0.0, double _grad 0.0, string _fa1 , string _fa2 ) : id(name), v(_value), grad(_grad), fa1(_fa1), fa2(_fa2), back_fn(nullptr) { } double v, grad; string id, fa1, fa2; function void(string, string, double, double) back_fn; friend ostream operator (ostream out, BaseDouble x) { outformat(BaseDouble{}(value{:.4f}, grad{:.4f}, fa[{}, {}]), x.id, x.v, x.grad, x.fa1, x.fa2)\n; return out; } friend ostream operator (ostream out, const BaseDouble x) { outformat(BaseDouble{}(value{:.4f}, grad{:.4f}, fa[{}, {}]), x.id, x.v, x.grad, x.fa1, x.fa2)\n; return out; } }; unordered_map string, BaseDouble pool; mt19937 mt(time(0)); unordered_map string, size_t opt_cnt; string auto_gen_name(string name1, string name2, string opt, int special_pid) { return format({}:{}:{}|{}, name1, opt, name2, to_string(special_pid)); } class vdouble { public: vdouble(string name , double val 0.0, string _fa1 , string _fa2 ) : id(name) { pool[name] BaseDouble(name, val, 0, _fa1, _fa2); } BaseDouble val() { if(id ) { throw runtime_error(vdouble.val: you must set id before using vdouble); } return pool[id]; } double item() const { if(id ) { throw runtime_error(vdouble.item: you must set id before using vdouble); } return pool[id].v; } string get_id() const { return id; } public: friend vdouble operator (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, , opt_cnt[]), pool[x.id].v pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad; pool[_fa2].grad ngrad; }; return ret; } friend vdouble operator - (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, -, opt_cnt[-]), pool[x.id].v - pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad; pool[_fa2].grad - ngrad; }; return ret; } friend vdouble operator * (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, *, opt_cnt[*]), pool[x.id].v * pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * pool[_fa2].v; pool[_fa2].grad ngrad * pool[_fa1].v; }; return ret; } friend vdouble operator / (const vdouble x, const vdouble y) { vdouble ret( auto_gen_name(x.id, y.id, /, opt_cnt[/]), pool[x.id].v / pool[y.id].v, x.id, y.id ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { auto a pool[_fa1].v; auto b pool[_fa2].v; pool[_fa1].grad ngrad * (1.0 / b); pool[_fa2].grad ngrad * (-a) / (b * b); }; return ret; } friend vdouble exp(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, exp, opt_cnt[exp]), std::exp(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * val; }; return ret; } friend vdouble sin(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, sin, opt_cnt[sin]), std::sin(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * std::cos(pool[_fa1].grad); }; return ret; } friend vdouble cos(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, cos, opt_cnt[cos]), std::cos(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * (-std::sin(pool[_fa1].grad)); }; return ret; } friend vdouble log(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, log, opt_cnt[log]), std::log(pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad ngrad * (1.0 / val); }; return ret; } friend vdouble relu(const vdouble x) { vdouble ret( auto_gen_name(x.id, None, relu, opt_cnt[relu]), std::max(0.0, pool[x.id].v), x.id, None ); ret.val().back_fn [](string _fa1, string _fa2, double ngrad, double val) { pool[_fa1].grad (pool[_fa1].v 0 ? ngrad : 0.0); }; return ret; } void backward() { pool[id].grad 1; _backward(id); } void zero_grad() { _zero_grad(id); } public: private: string id; void _backward(string now) { if(pool[now].fa1 pool[now].fa2 ) { return ; } pool[now].back_fn(pool[now].fa1, pool[now].fa2, pool[now].grad, pool[now].v); if(pool[now].fa1 ! ) _backward(pool[now].fa1); if(pool[now].fa2 ! ) _backward(pool[now].fa2); } void _zero_grad(string now) { pool[now].grad 0.0; if(pool[now].fa1 ! ) _zero_grad(pool[now].fa1); if(pool[now].fa2 ! ) _zero_grad(pool[now].fa2); } }; void show() { coutshow values in pool\n; for(auto [id, val] : pool) { coutval\n; } } int main() { vdouble a(a, 1.0); vdouble b(b, 0.0); vdouble c(c, -2.0); vdouble d(d, 1.0); vdouble two(two, 2.0); auto e exp(a); auto term1 e * two; auto s sin(b); auto add_log a d; auto l log(add_log); auto r relu(c); auto y term1 s - l r; y.backward(); show(); /* 标准答案 a.grad 3.9939 b.grad 1.0000 c.grad 0.0000 d.grad -1.4427 two.grad 2.7183 */ return 0; }我们已经实现了autograd的核心计算部分知识补充:你还知道那些激活函数, 自行预习你知道为什么要有激活函数吗自己使用vdouble写一个简单的函数求导了解MLP多层神经网络(Pytorch的nn.Linear/TensorFlow.keras.Dense)ok, 结尾散花 ...........未完待续 ing .........下一步完善vdouble, 并写出第一个神经网络MLP!!推荐一波: 李沐老师https://zh-v2.d2l.ai/

更多文章