纯 C++ 机器学习 —— network.h 使用指南
Exber
·
·
科技·工程
本文同步发布于我的博客,洛谷上的版本不一定是最新的,若要查看最新版请移步我的博客。
项目地址
- 2025.01.18 更新:去除了预设层级和优化器的成员变量
int bs
,用户不再需要为每个层级和优化器都指定批大小,仅需在 auto_dao::init()
中指定即可;
- 2025.01.30 更新:计算加速改用 Eigen + ViennaCL,改善了 GPU 上计算的性能;
- 2025.02.06 更新:更正了 g++ 下的优化命令;
- 2025.02.13 更新:增加了原地计算功能,修复自动保存/读取的 bug,优化
FC
层计算速度,优化了各层级申请内存所需的时间,优化运行所需的内存大小;
简介
基于 C++14 的仅头文件的神经网络库,代码可读且速度较快,方便研究神经网络的实现。
计算加速:
- CPU 加速:基于 Eigen(内附 3.4.0 版本,无需设置额外引用目录);
- GPU 加速:基于 Eigen 和使用 OpenCL 的 ViennaCL(需要搭建 OpenCL 环境并设置额外引用目录) ;
CPU 代码支持大部分编译器,可用基于 GCC 的 DEV-C++ 编译。
对于 GPU 代码:
- Windows 下仅支持 VS 系列编译器编译(存疑,作者并未在其它环境下成功);
- Linux 下情况不明,作者没有尝试;
支持自动求偏导(反向传播),用户仅需定义前向过程。
支持读取/保存图片文件,使用了开源库 stb。
本文仅介绍库的使用方法,关于机器学习的原理部分请移步:[咕咕咕]()
注意事项
关于各种常量及开关
- 定义
ENABLE_GPU
以启用 GPU 计算(ViennaCL on OpenCL);
- 定义
ENABLE_AUTO_SL
以启用自动生成保存/读取/释放内存函数相关功能;
- 使用宏
MAX_BUFSIZE_BYTE
以控制计算过程中额外使用的内存(显存)大小(防止爆内存/显存),默认值为 1073741824
,即 1\text{GB};(该功能尚未完善,额外内存可能超过该值,故建议尽量设置小一点)
关于计算加速
由于矩阵乘法算子基于 Eigen 和使用 OpenCL 的 ViennaCL,故仅需加速此两库即可。具体可以通过启用 OpenMP 和各种指令集(AVX、SSE)来加速,例如在 GCC 下使用 -Ofast -fopenmp -march=native
编译命令来启用 OpenMP 和指令集并开启 Ofast 优化。
基本概念
训练阶段
在此阶段,数据以批为单位进入神经网络,执行前向过程,计算出结果;再根据结果对应的损失值,反向传播得出各参数偏导,通过优化器更新参数。
测试阶段
在测试阶段,数据一个一个进入神经网络,执行前向过程,计算出结果。
该阶段中反向传播会被禁用,且某些层级算子的行为会改变(例如批归一化层)。
张量、自动反向传播
头文件 auto_dao.h
。
命名空间 auto_dao
成员 |
含义/作用 |
int Batch_Size |
批大小,若为 0 则表示当前为测试阶段而非训练阶段 |
struct node |
内部结构体,用户无需访问 |
std::vector<node*> tmp |
内部变量,记录当前申请的所有 node 的地址,方便释放内存及初始化反向传播 |
void init(int BatchSize) |
前向过程前必须执行的函数,释放现有所有张量占用的内存空间,初始化 Batch_Size |
init_backward() |
反向传播前必须执行的函数,初始化现有所有张量以便反向传播 |
三维张量 val3d
定义了可自动求偏导(反向传播)的三维张量类型 val3d
。在训练阶段,张量会同时存储整个批次中的数据(所以它实际上是四维的)。val3d
会自动记录前向过程,方便反向传播。
成员 |
含义/作用 |
int d |
张量的通道数 |
int h |
张量的高度 |
int w |
张量的宽度 |
float* a |
张量数值的起始地址(数值按照 auto_dao::Batch_Size*d*h*w 的方式存储) |
float* da |
张量偏导的起始地址(偏导按照 auto_dao::Batch_Size*d*h*w 的方式存储,与数值一一对应) |
val3d() |
默认构造函数(不进行任何操作) |
val3d(int td,int th,int tw,float val=0) |
构造函数,初始化一个 td*th*tw 的张量,其每个位置的数值都是 val |
val3d(int td,int th,int tw,float *dat) |
构造函数,根据 dat[0] 到 dat[max(auto_dao::Batch_Size,1)*td*th*tw-1] 中的数值初始化一个 td*th*tw 的张量 |
backward() |
将该张量的偏导传递下去* |
auto_dao::node *dat |
内部变量,不使用 private 关键字仅仅是为了增加代码可读性,避免大量 friend 关键字 |
*:调用 backward
时,会将该张量标记为“偏导计算完成”状态,并将该张量的偏导反向传播至对其有影响的张量处。若本次操作导致某个张量的偏导计算完成(影响到的所有张量的偏导都已传播至该张量),则会自动调用其 backward
函数(类似 DAG 上反向 bfs)。故用户仅需在手动计算所有输出端张量的偏导后,手动调用所有输出端张量的 backward
函数。
关于原地计算(inplace
选项)
为了节省空间,某些操作具有 inplace
选项,inplace=true
的操作将在原地完成。此时该操作会复用原本张量的空间,使得原本的张量失效。
所以经过 inplace=true
的操作后原始张量将失效,请勿再使用它。
张量相关函数
函数 |
含义/作用 |
val3d reshape(val3d x,int d,int h,int w,bool inplace=false) |
软塑形:创建一个新的三维张量,d,h,w 为传入的 d,h,w ,数据从 x.a 拷贝(需保证 d*h*w==x.d*x.h*x.w ) |
val3d toshape(val3d x,int d,int h,int w) |
硬塑形:创建一个新的三维张量,d,h,w 为传入的 d,h,w ,数据从 x.a 循环拷贝,即 i,j,k 处的数值为 x 的 i%x.d,j%x.h,k%x.w 处的数值 |
val3d operator+(val3d x,val3d y) |
创建一个新的三维张量,其每一位的数值都是 x 对应位置的数值和 y 对应位置的数值相加(需保证 x 和 y 形状相同) |
val3d operator-(val3d x,val3d y) |
同上,相加变为相减 |
val3d operator*(val3d x,val3d y) |
同上,相乘 |
val3d operator/(val3d x,val3d y) |
同上,相除(x 为被除数) |
val3d dcat(val3d x,val3d y) |
创建一个新的三维张量,其是 x 和 y 按照 d 这一维拼接起来的结果(x 占用 [0,x.d-1] ,y 占用 [x.d,x.d+y.d-1] ,需保证 x.h==y.h 且 x.w==y.w ) |
float MSEloss(val3d x,float* realout) |
使用 realout[0] 到 realout[max(auto_dao::Batch_Size,1)*x.d*x.h*x.w] 中的数据为三维张量 x 计算均方差损失,同时为 x 计算偏导 |
float BCEloss(val3d x,float* realout) |
同上,但计算的是二元交叉熵损失 |
均方差损失(MSEloss)
$$
loss=\frac{1}{n}\sum\limits_{i=1}^n(x_i-r_i)^2
$$
##### 二元交叉熵损失(BSEloss)
$x$ 是输出,$r$ 是真实数据:
$$
loss=-\frac{1}{n}\sum\limits_{i=1}^nr_i\ln(x_i)+(1-r_i)\ln(1-x_i)
$$
注意 $x_i$ 会被限制在 $[10^{-7},1-10^{-7}]$ 内以防止出现 `inf` 或 `nan`,若超出范围则偏导为 $0$。
### 预设优化器
头文件:`Optimizer/*.h`
命名规则:全大写命名
在本库中,权重及其在反向传播中求得的偏导统一存储于优化器中,方便统一更新。
统一公有成员:
| 成员 | 含义/作用 |
| :-------------------------------: | :----------------------------------------------------------: |
| ` bool built` | 是否完成初始化 |
| `int m` | 权重数量 |
| `float lrt` | 学习率 |
| `void init(float Learn_Rate,...)` | 初始化优化器参数,该函数的第一个参数及含义固定,为学习率,根据不同优化器具体情况可能有更多参数 |
| `void build()` | 初始化优化器,为权重及其偏导分配内存空间 |
| `void save(std::ofstream& ouf)` | 将优化器参数及权重保存到二进制文件流 `ouf` 中 |
| `void load(std::ifstream& inf)` | 从二进制文件流 `inf` 中读取优化器参数及权重 |
| `void delthis()` | 释放申请的内存空间 |
| `float* _wei()` | 获取权重数组起始地址 |
| `float* _tmp()` | 获取偏导数组起始地址 |
| `void init_backward()` | 清空累计的偏导,即将偏导数组置零,准备反向传播 |
| `void flush()` | 利用当前累计的偏导更新权重,在反向传播完成后调用 |
| 默认构造函数 | 初始化 `built=false`,当启用宏 `ENABLE_AUTO_SL` 时还用于自动生成神经网络的保存、读取和空间释放函数(实现静态反射) |
参数命名规则和主流的神经网络库大致相同。
#### SGD 优化器
头文件:`Optimizer/SGD.h`
定义了优化器类型 `SGD`,其所有公有成员均无特殊。
参数更新方式:($w$ 为参数,$\Delta$ 为偏导)
$$
w_i\to w_i-lrt\times \Delta_i
$$
#### Adam 优化器
头文件:`Optimizer/Adam.h`
定义了优化器类型 `ADAM`,其特殊成员如下:
| 成员 | 含义/作用 |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| `float b1` | 参数更新公式中的 $\beta_1$ |
| `float b2` | 参数更新公式中的 $\beta_2$ |
| `float eps` | 参数更新公式中的 $\epsilon$,一个很小的非负实数,防止除以 $0$ |
| `void init(float Learn_Rate,float beta1=0.9,float beta2=0.999,float Eps=1e-8)` | 初始化优化器参数,`b1`,`b2` 和 `eps` 分别初始化为 `beta1`,`beta2` 和 `Eps` |
参数更新方式:($w$ 为参数,$\Delta$ 为偏导)
- 初始 $v_i\to 0,s_i\to 0$;
- 对于第 $t$ 次更新:
$$
v_i\to \beta_1v_i+(1-\beta_1)\Delta_i\\
s_i\to \beta_2s_i+(1-\beta_2)\Delta_i^2\\
\bar v_i=\frac{v_i}{1-\beta_1^t}\\
\bar s_i=\frac{s_i}{1-\beta_2^t}\\
w_i\to w_i-lrt\times \frac{\bar v_i}{\sqrt{\bar s_i}+\epsilon}
$$
### 预设网络层级(层级算子)
头文件:`Layers/*.h`
命名规则:全大写命名
统一公有成员:(有可训练权重)
| 成员 | 含义/作用 |
| :-----------------------------------------------------: | :----------------------------------------------------------: |
| ` bool built` | 是否完成初始化 |
| `void init(int& m,...)` | 初始化层级参数,该函数第一个参数及其含义固定,为权重计数器(用于统计权重数量,一般传入优化器的 `m`)。根据不同层级的具体情况可能有更多参数 |
| `void build(float*& wei,float*& tmp,...)` | 为层级分配权重、偏导储存空间并初始化权重,其中 `wei` 为权重储存起始地址,`tmp` 为偏导储存起始地址 |
| `void save(std::ofstream& ouf)` | 将层级参数保存到二进制文件流 `ouf` 中,权重并不会被保存 |
| `void load(std::ifstream& inf,float*& wei,float*& tmp)` | 从二进制文件流 `inf` 中读取层级参数,并根据 `wei` 和 `tmp` 为层级分配权重* |
| `val3d operator()(val3d x)` | 在三维张量 `x` 上应用该层级的操作并返回结果 |
| 默认构造函数 | 初始化 `built=false`,在启用宏 `ENABLE_AUTO_SL` 时还用于自动生成神经网络的保存、读取和空间释放函数(实现静态反射) |
*:`wei` 为权重数组起始地址,`tmp` 为偏导数组起始地址,层级将会从 `wei` 中获取其权重并分配空间(这要求优化器的 `load()` 函数已经被调用)。
统一公有成员:(无可训练权重)
| 成员 | 含义/作用 |
| :-----------------------------: | :----------------------------------------------------------: |
| ` bool built` | 是否完成初始化 |
| `void init(...)` | 初始化层级参数,根据不同层级的具体情况可能有更多参数 |
| `void save(std::ofstream& ouf)` | 将层级参数保存到二进制文件流 `ouf` 中 |
| `void load(std::ifstream& inf)` | 从二进制文件流 `inf` 中读取层级参数 |
| `val3d operator()(val3d x)` | 在三维张量 `x` 上应用该层级的操作并返回结果 |
| 默认构造函数 | 初始化 `built=false`,在启用宏 `ENABLE_AUTO_SL` 时还用于自动生成神经网络的保存、读取和空间释放函数(实现静态反射) |
#### 全连接层(FC)
头文件:`Layers/fc.h`
定义了全连接层类型 `FC`,其特殊成员如下:
| 成员 | 含义/作用 |
| :--------------------------------------------------------: | :----------------------------------------------------------: |
| `int ins` | 输入值个数 |
| `int ous` | 输出值个数 |
| `float* w` | 权重存储起始地址 |
| `void init(int& m,int INS,int OUS)` | 初始化层级参数,额外将 `ins` 和 `ous` 初始化为 `INS` 和 `OUS` |
| `void build(float*& wei,float*& tmp,int InitType=INIT_HE)` | 为层级分配权重、偏导储存空间并按照 `InitType` 的方式初始化 `w`(`Xavier` 或 `HE`) |
`FC` 层将会接受大小满足 `d*h*w=ins` 的三维张量输入,并按照 `w` 加权求和后变换为大小为 `ous*1*1` 的三维张量。
#### 偏置层(BIAS)
头文件:`Layers/bias.h`
定义了偏置层类型 `BIAS`,其特殊成员如下:
| 成员 | 含义/作用 |
| :--------------------------------------------------: | :----------------------------------------------------------: |
| `int d` | 输入张量的通道数 |
| `int h` | 输入张量的高度 |
| `int w` | 输入张量的宽度 |
| `float* b` | 权重存储起始地址 |
| ` bool inplace` | 是否原地计算 |
| `void init(int& m,SHAPE3D Input,bool Inplace=false)` | 初始化层级参数,额外利用 `Input` 的三维大小初始化 `d,h,w`,并使用 `Inplace` 初始化 `inplace` |
| `void build(float*& wei,float*& tmp)` | 为层级分配权重、偏导储存空间,将 `b` 初始化为全 $0$ |
`BIAS` 层将会接受大小为 `d*h*w` 的三维张量输入,并为第 $i$ 个通道的所有值增加 `b[i]` 的偏置后输出。
#### 卷积层(CONV)
头文件:`Layers/conv.h`
定义了卷积层类型 `CONV`,其特殊成员如下:
| 成员 | 含义/作用 |
| :--------------------------------------------------------: | :----------------------------------------------------------: |
| `int ind` | 输入张量的通道数 |
| `int inh` | 输入张量的高度 |
| `int inw` | 输入张量的宽度 |
| `int cnt` | 卷积核的个数(输出张量的通道数) |
| `int ch` | 卷积核的高度 |
| `int cw` | 卷积核的宽度 |
| `int stx` | 卷积核在高度方向上的步长 |
| `int sty` | 卷积核在宽度方向上的步长 |
| `int pdx` | 高度方向上的 Padding 大小(上下都会补充 `pdx` 个 `pdval`) |
| `int pdy` | 宽度方向上的 Padding 大小(左右都会填充 `pdy` 个 `pdval`) |
| `float pdval` | Padding 的值 |
| `int ouh` | 输出张量的高度 |
| `int ouw` | 输出张量的宽度 |
| `float* w` | 权重存储起始地址 |
| `void init(...)` | 初始化层级参数* |
| `void build(float*& wei,float*& tmp,int InitType=INIT_HE)` | 为层级分配权重、偏导储存空间并按照 `InitType` 的方式初始化 `w`(`Xavier` 或 `HE`) |
*:`init()` 函数将初始化卷积层参数并计算出 `ouh` 和 `ouw`,详细声明及特殊参数含义如下:
```cpp
void init(int& m,
SHAPE3D Input,
int CoreCnt,std::pair<int,int> Core,
std::pair<int,int> Stride={1,1},
std::pair<int,int> Padding={0,0},float PaddingVal=0)
```
| 参数 | 含义/作用 |
| :--------------------------: | :----------------------------------------------------------: |
| `SHAPE3D Input` | 使用 `Input` 三维的值分别初始化 `ind,inh,inw` |
| `int CoreCnt` | 使用该值初始化卷积核个数 `cnt` |
| `std::pair<int,int> Core` | 初始化卷积核大小,`ch=Core.first, cw=Core.second` |
| `std::pair<int,int> Stride` | 初始化步幅大小,`stx=Stride.first, sty=Stride.second` |
| `std::pair<int,int> Padding` | 初始化 Padding 大小,`pdx=Padding.first, pdy=Padding.second` |
| `float PaddingVal` | 使用该值初始化 `pdval` |
调用 `init()` 函数后将自动初始化 `ouh=(inh+pdx*2-ch)/stx+1, ouw=(inw+pdy*2-cw)/sty+1`。
`CONV` 层接受大小为 `ind*inh*inw` 的三维张量输入,并做卷积操作后输出大小为 `cnt*ouh*ouw` 的三维张量。
#### 反卷积层(DECONV)
头文件:`Layers/deconv.h`
定义了反卷积层类型 `DECONV`,其特殊成员及其含义与 `CONV` 类型相同,但没有 `pdval` 及 `PaddingVal`,即输入张量的每个值乘上卷积核后叠加到输出张量上,`cnt*ouh*oud` 的三维张量经过参数(仅交换 `cnt` 和 `ind`)一样的 `CONV` 后会变为 `ind*inh*inw` 的三维张量。
具体细节不再赘述,详见[咕咕咕]()。
#### 池化层(POOLING)
头文件:`Layers/pooling.h`
定义了池化层类型 `POOLING`,其特殊成员如下:
| 成员 | 含义/作用 |
| :--------------: | :---------------------------------: |
| `int ind` | 输入张量的通道数 |
| `int inh` | 输入张量的高度 |
| `int inw` | 输入张量的宽度 |
| `int ch` | 池化核的高度 |
| `int cw` | 池化核的宽度 |
| `int tpe` | 池化操作类型(最大池化/均值池化) |
| `int stx` | 池化核在高度方向上的步长 |
| `int sty` | 池化核在宽度方向上的步长 |
| `int ouh` | 输出张量的高度 |
| `int ouw` | 输出张量的宽度 |
| `void init(...)` | 初始化层级参数* |
*:`init()` 函数将初始化池化层参数并计算出 `ouh` 和 `ouw`,详细声明及特殊参数含义如下:
```cpp
inline void init(SHAPE3D Input,
std::pair<int,int> Core,
int Type=MAX_POOLING,
std::pair<int,int> Stride={-1,-1})
```
| 参数 | 含义/作用 |
| :-------------------------: | :----------------------------------------------------------: |
| `SHAPE3D Input` | 使用 `Input` 三维的值分别初始化 `ind,inh,inw` |
| `std::pair<int,int> Core` | 初始化池化核大小,`ch=Core.first, cw=Core.second` |
| `int Type` | 初始化池化操作类型,`MAX_POOLING` 表示最大池化,`MEAN_POOLING` 表示均值池化 |
| `std::pair<int,int> Stride` | 初始化步幅大小,`stx=Stride.first, sty=Stride.second`,特别的,若某一项为 `-1` 则表示该项取池化核的对应参数 |
调用 `init()` 函数后将自动初始化 `ouh=(inh+stx-1)/stx, ouw=(inw+sty-1)/sty`。
`POOLING` 层接受大小为 `ind*inh*inw` 的三维张量输入,并做池化操作后输出大小为 `ind*ouh*ouw` 的三维张量。
#### 拓展层(EXT)
头文件:`Layers/ext.h`
定义了拓展层类型 `EXT`,其效果是将每个位置的值在原地复制若干份(变胖),特殊成员如下:
| 成员 | 含义/作用 |
| :------------------------------------------------: | :----------------------------------------------------------: |
| `int ind` | 输入张量的通道数 |
| `int inh` | 输入张量的高度 |
| `int inw` | 输入张量的宽度 |
| `int filx` | 填充高度 |
| `int fily` | 填充宽度 |
| `int ouh` | 输出张量的高度 |
| `int ouw` | 输出张量的宽度 |
| `void init(SHAPE3D Input,std::pair<int,int> Fill)` | 初始化层级参数,使用 `Input` 三维的值分别初始化 `ind,inh,inw`,使用 `Fill` 两维的值分别初始化 `filx` 和 `fily` |
调用 `init()` 函数后将自动初始化 `ouh=inh*filx, ouw=inw*fily`。
`EXT` 层接受大小为 `ind*inh*inw` 的三维张量输入,将每个值在原地变为 `filx*fily` 的值相等的矩形后输出大小为 `ind*ouh*ouw` 的三维张量。
#### 批归一化层(BN)
头文件:`Layers/bn.h`
定义了批归一化层类型 `BN`,特殊成员如下:
| 成员 | 含义/作用 |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| `int d` | 输入张量的通道数 |
| `int h` | 输入张量的高度 |
| `int w` | 输入张量的宽度 |
| `float delta` | 滑动平均参数 $\delta$ |
| `float eps` | 极小量 $\epsilon$,防止让方差变得很小以至于除以零 |
| `float* k` | 系数数组起始地址 |
| `float* b` | 偏置数组起始地址 |
| `float* e_avg` | 均值的滑动平均,用于测试时的前向过程 |
| `float* e_var` | 方差的滑动平均,用于测试时的前向过程 |
| `void init(int& m,SHAPE3D Input,float Delta=0.9,float EPS=1e-4)` | 初始化层级参数,使用 `Input` 三维的值分别初始化 `d,h,w`,使用 `Delta` 和 `EPS` 分别初始化 `delta` 和 `eps` |
| `void build(float*& wei,float*& tmp)` | 为层级分配权重、偏导储存空间并将 `k` 和 `e_var` 初始化为全 $1$,`b` 和 `e_avg` 初始化为全 $0$ |
`BN` 层将会**对所有批的输入数据一起操作**,为输入张量的每个通道做批归一化,而输出的三维张量形状不变。具体的,假设这是第 $i$ 个通道,将通道内部所有位置所有批的值拿出来放入数组 $a$ 中(假设共 $n$ 个),则在训练阶段得到对应的输出 $\overline{a}$ 的流程为:
$$
\mu_i=\frac{1}{n}\sum\limits_{j=1}^{n} a_i\\
\sigma_i=\frac{1}{n}\sum\limits_{j=1}^{n} (a_j-\mu_i)^2\\
\overline{a}_j=k_i\frac{a_j-\mu_i}{\sqrt{\sigma_i+\epsilon}}+b_i
$$
并且 `e_avg` 和 `e_var` 在每次训练阶段的前向过程都会做如下更新:
$$
e\_avg_i=\delta\times e\_avg_i +(1-\delta)\times \mu_i\\
e\_var_i=\delta\times e\_var_i +(1-\delta)\times \sigma_i\\
$$
在测试阶段,由于数据量较小,均值和方差往往不够准确,故采用之前的滑动平均来计算输出:
$$
\overline{a}_j=k_i\frac{a_j-e\_avg_i}{\sqrt{e\_var_i+\epsilon}}+b_i
$$
#### 组归一化层(GN)
头文件:`Layers/gn.h`
定义了组归一化层类型 `GN`,特殊成员如下:
| 成员 | 含义/作用 |
| :----------------------------------------------: | :----------------------------------------------------------: |
| `int d` | 输入张量的通道数 |
| `int h` | 输入张量的高度 |
| `int w` | 输入张量的宽度 |
| `int g` | 每组的通道数 |
| `float eps` | 极小量 $\epsilon$,防止让方差变得很小以至于除以零 |
| `int cnt` | 组数 |
| `float* k` | 系数数组起始地址 |
| `float* b` | 偏置数组起始地址 |
| `void init(int& m,SHAPE3D Input,float EPS=1e-4)` | 初始化层级参数,使用 `Input` 三维的值分别初始化 `d,h,w`,使用 `EPS` 初始化 `eps` |
| `void build(float*& wei,float*& tmp)` | 为层级分配权重、偏导储存空间并将 `k` 初始化为全 $1$,`b` 初始化为全 $0$ |
调用 `init()` 函数后将自动初始化 `cnt=d/g+(d%g!=0)`。
`GN` 层将会**对每个批的数据分别操作**,将输入张量每连续的至多 $g$ 个通道分为一组,共 $cnt$ 组,每组内做和 `BN` 层大致相同的归一化操作,而输出的三维张量形状不变。
由于是对每个批的数据分别操作,故测试时的前向过程和训练时一致。
#### Softmax 归一化层(Softmax)
头文件:`Layers/Softmax.h`
定义了 Softmax 归一化层类型 `SOFTMAX`,特殊成员如下:
| 成员 | 含义/作用 |
| :------------------------: | :-----------------------------------------------------: |
| `int d` | 输入张量的通道数 |
| `int h` | 输入张量的高度 |
| `int w` | 输入张量的宽度 |
| `void init(SHAPE3D Input)` | 初始化层级参数,使用 `Input` 三维的值分别初始化 `d,h,w` |
`SOFTMAX` 层将会对输入张量沿着通道做 Softmax 操作,输出张量三维形状不变,即对于位置 $x,y$ 上的 $d$ 个值,设其分别为 $a_{[1,d]}$,则输出 $\overline a_{[1,d]}$ 的计算方法如下:
$$
\overline{a}_i=\frac{e^{a_i}}{\sum\limits_{j=1}^d e^{a_j}}
$$
#### 各种激活函数层
公共特殊成员:
| 成员 | 含义/作用 |
| :-----------------------------------------: | :----------------------------------------------------------: |
| `int siz` | 输入张量的大小(`d*h*w`) |
| `bool inplace` | 是否原地计算 |
| `void init(int Siz,bool Inplace=false,...)` | 初始化层级参数,前两个参数及其含义固定(使用 `Siz` 初始化 `siz`,使用 `Inplace` 初始化 `inplace`),若有更多参数将给出说明 |
各种激活函数层将对输入张量的每个数值分别应用对应的激活函数 $f$,即 $x_{\text{out}}=f(x_{\text{in}})$,输出张量三维形状不变。
##### ReLU 层(RELU)
头文件:`Layers/ReLU.h`
定义了 ReLU 层类型 `RELU`:
$$
f(x)=\max(x,0)
$$
##### Leaky_ReLU 层(LEAKY_RELU)
头文件:`Layers/Leaky_ReLU.h`
定义了 Leaky_ReLU 层类型 `LEAKY_RELU`,其特殊成员如下:
| 成员 | 含义/作用 |
| :-----------------------------------: | :-----------------------------------------: |
| `float a` | 激活函数 $f$ 中的参数 $\alpha$ |
| `void init(int Siz,float Alpha=0.01)` | 初始化层级参数,额外使用 `Alpha` 初始化 `a` |
激活函数表达式如下:
$$
f(x)=\begin{cases}\alpha x&x<0\\x&x\ge 0\end{cases}
$$
##### Sigmoid 层(SIGMOID)
头文件:`Layers/Sigmoid.h`
定义了 Sigmoid 层类型 `SIGMOID`:
$$
f(x)=\frac{1}{1+e^{-x}}
$$
##### Tanh 层(TANH)
头文件:`Layers/Tanh.h`
定义了 Tanh 层类型 `TANH`:
$$
f(x)=\frac{e^x-e^{-x}}{e^x+e^{-x}}
$$
### 自动生成保存/读取/释放空间函数
头文件:`auto_saveload.h`,定义宏 `ENABLE_AUTO_SL` 以启用。
原理是使用构造函数创建反射,依次执行预设优化器和所有预设层级的对应函数。
#### 基础用法
在用户定义的神经网络类中,将预设优化器的声明放置于所有预设层级声明的前面,并且不能有多个预设优化器。
在预设优化器声明前加上 `AUTO_SL_BEG` 关键字,在所有预设层级声明的末尾加上 `AUTO_SL_BEG` 关键字。
将会自动定义类的三个成员变量 `save`、`load` 和 `delthis`,并自动定义其 `()` 运算符。
例子:
```cpp
class network
{
AUTO_SL_BEG
ADAM opt;
FC fc1,fc2,fc3;
CONV c1,c2;
AUTO_SL_END
}test;
```
使用 `test.save(path)` 和 `test.load(path)` 以保存到文件或从文件中读取。
使用 `test.delthis()` 以释放该神经网络类预设优化器及各预设层级占用的内存空间。
#### 进阶用法
对于用户自己定义的层级/优化器,可以再其构造函数中使用如下几个宏自动生成对应的反射注册代码:
```cpp
AUTO_SL_LAYER_CONSTRUCTER_WEIGHT_DELTHISFUNC(weight_add)
AUTO_SL_LAYER_CONSTRUCTER_WEIGHT(weight_add)
AUTO_SL_LAYER_CONSTRUCTER_DELTHISFUNC
AUTO_SL_LAYER_CONSTRUCTER
AUTO_SL_OPTIMIZER_CONSTRUCTER
```
其中 `weight_add` 为指向该层级的权重起始地址的指针,用于在保存/读取时确定各层级间权重分配顺序。
具体详见 `auto_saveload.h` 中的注释及源代码。
### 图片读写及其它文件操作
头文件 `file_io.h`
定义了若干文件操作(包含图片读写)函数:
| 函数 | 含义/作用 |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| `void readf(std::ifstream& inf,T& x)` | 从二进制输入流 `inf` 中读取类型 `T` 的数据到 `x` 中 |
| `void readf(std::ifstream& inf,T* x,int siz)` | 从二进制输入流 `inf` 中读取连续的 `siz` 个类型 `T` 的数据到 `x` 起始的数组中 |
| `writf(std::ofstream& ouf,T x)` | 将类型 `T` 的数据 `x` 输出到二进制输出流 `ouf` 中 |
| `writf(std::ofstream& ouf,T* x,int siz)` | 将从 `x` 起始的连续 `siz`个类型 `T` 的数据依次输出到二进制输出流 `ouf` 中 |
| `void readimg(std::string path,int& d,int& h,int& w,float* img,float l=-1,float r=1)` | 读取 `path` 对应的图片文件,将其通道数和高度、宽度分别储存在 `d,h,w` 中,将其每个像素点每个通道的值归一化到 $[l,r]$ 后按 `d*h*w` 的格式储存在 `img` 中 |
| `void readimg(std::string path,float* img,float l=-1,float r=1)` | 含义基本同上,但不储存三维大小 |
| `void savejpg(std::string path,int d,int h,int w,float* img,float l=-1,float r=1,int quality=100)` | 将起始地址为 `img` 的一张归一化到 $[l,r]$ 的 `d*h*w` 的图片以 `jpg` 的格式保存在文件 `path` 中,图片质量为 `quality` |
| `void savepng(std::string path,int d,int h,int w,float* img,float l=-1,float r=1)` | 含义基本同上,但保存格式为 `png` 且无图片质量参数 |
| `void savebmp(std::string path,int d,int h,int w,float* img,float l=-1,float r=1)` | 含义基本同上,但保存格式为 `bmp` |
| `void getfiles(std::string path,std::vector<std::string>& files)` | 获取目录 `path` 下的所有文件,并将其路径保存到 `files` 中(也会获取更深的所有子目录中的文件) |
### 其它头文件
| 头文件 | 含义/作用 |
| :-----------: | :----------------------------------------------------------: |
| `stb/*.h` | 开源库 [stb](https://github.com/nothings/stb) 中的若干头文件 |
| `Eigen/*` | 开源库 [Eigen](https://eigen.tuxfamily.org/index.php?title=Main_Page) 的 3.4.0 版本 |
| `fast_calc.h` | 定义了快速矩阵乘法函数 |
| `defines.h` | 各头文件的公共定义、引用 |
| `network.h` | 库入口 |
### 基于 [MNIST](https://github.com/Rebxe/MNIST) 的 demo
```cpp
#include <iostream>
#include <cstdlib>
#include <cstdio>
#include <chrono>
#define ENABLE_AUTO_SL
#include "./network_h/network.h"
using namespace std;
const int T = 60000, TEST_T = 10000;
const int Batch_Size = 60;
const float lrt = 0.001;
const int total_batch = 5000, calctme = 5;
struct NETWORK
{
AUTO_SL_BEG
ADAM opt;
CONV c1;
BN b1;
LEAKY_RELU a1;
POOLING p1;
CONV c2;
BN b2;
LEAKY_RELU a2;
POOLING p2;
FC fc1;
BIAS bi1;
LEAKY_RELU a3;
FC fc2;
BIAS bi2;
SOFTMAX sfm1;
AUTO_SL_END
float in[Batch_Size * 28 * 28];
val3d out;
inline void init()
{
opt.init(lrt);
c1.init(opt.m,SHAPE3D(1,28,28),8,{3,3},{1,1},{1,1},0);
b1.init(opt.m,SHAPE3D(8,28,28));
a1.init(8*28*28,true);
p1.init(SHAPE3D(8,28,28),{2,2});
c2.init(opt.m,SHAPE3D(8,14,14),16,{3,3},{1,1},{1,1},0);
b2.init(opt.m,SHAPE3D(16,14,14));
a2.init(16*14*14,true);
p2.init(SHAPE3D(16,14,14),{2,2});
fc1.init(opt.m,16*7*7,128);
bi1.init(opt.m,SHAPE3D(128,1,1),true);
a3.init(128,true);
fc2.init(opt.m,128,10);
bi2.init(opt.m,SHAPE3D(10,1,1),true);
sfm1.init(SHAPE3D(10,1,1));
opt.build();
float *wei=opt._wei(),*tmp=opt._tmp();
c1.build(wei,tmp),b1.build(wei,tmp);
c2.build(wei,tmp),b2.build(wei,tmp);
fc1.build(wei,tmp),bi1.build(wei,tmp);
fc2.build(wei,tmp,INIT_XAVIER),bi2.build(wei,tmp);
}
inline void forward(bool test)
{
auto_dao::init(test?0:Batch_Size);
val3d x(1,28,28,in);
x=c1(x),x=b1(x),x=a1(x),x=p1(x);
x=c2(x),x=b2(x),x=a2(x),x=p2(x);
x=fc1(x),x=bi1(x),x=a3(x);
x=fc2(x),x=bi2(x),x=sfm1(x);
out=x;
}
inline float backward(float *rout)
{
opt.init_backward();
float res=MSEloss(out,rout);
out.backward();
opt.flush();
return res;
}
};
float casin[T + 5][28 * 28];
int casans[T + 5];
float outs[Batch_Size * 10];
float total_loss;
NETWORK brn;
inline void loaddata(string imgpath,string anspath,int T)
{
FILE* fimg = fopen(imgpath.c_str(), "rb");
FILE* fans = fopen(anspath.c_str(), "rb");
if (fimg == NULL)
{
puts("加载图片数据失败\n");
system("pause");
exit(1);
}
if (fans == NULL)
{
puts("加载答案数据失败\n");
system("pause");
exit(1);
}
fseek(fimg, 16, SEEK_SET);
fseek(fans, 8, SEEK_SET);
unsigned char* img = new unsigned char[28 * 28];
for (int cas = 1; cas <= T; cas++)
{
fread(img, 1, 28 * 28, fimg);
for (int i = 0; i < 28 * 28; i++) casin[cas][i] = img[i] / (float)255;
unsigned char num;
fread(&num, 1, 1, fans);
casans[cas] = num;
}
delete[] img;
fclose(fimg), fclose(fans);
}
void train()
{
int cins = 0, couts = 0;
for (int tb = 1; tb <= Batch_Size; tb++)
{
int cas = (rand() * (RAND_MAX + 1) + rand()) % T + 1;
for (int i = 0; i < 28 * 28; i++) brn.in[cins++] = casin[cas][i];
for (int i = 0; i < 10; i++) outs[couts++] = casans[cas] == i;
}
brn.forward(false);
total_loss+=brn.backward(outs);
}
inline bool test(int cas)
{
for (int i = 0; i < 28 * 28; i++) brn.in[i] = casin[cas][i];
brn.forward(true);
int mxid = 0;
for (int i = 1; i < 10; i++) if (brn.out.a[i] > brn.out.a[mxid]) mxid = i;
return mxid == casans[cas];
}
int main()
{
printf("模式选择:\n");
printf("[1] 加载 AI 并测试\n");
printf("[2] 训练 AI(最好的 AI 模型将会保存到 ./best.ai)\n");
int mode;
scanf("%d", &mode);
system("cls");
string imgpath = "./MNIST/img",
anspath = "./MNIST/ans",
testimgpath = "./MNIST/testimg",
testanspath = "./MNIST/testans";
printf("训练图片文件:%s\n", imgpath.c_str());
printf("训练答案文件:%s\n", anspath.c_str());
printf("评估图片文件:%s\n", testimgpath.c_str());
printf("评估答案文件:%s\n\n", testanspath.c_str());
if (mode == 1)
{
printf("请输入之前保存的 AI 路径\n");
string path;
cin >> path;
brn.load(path);
}
else
{
printf("加载数据中...\n");
loaddata(imgpath,anspath,T);
printf("加载数据完成\n\n");
brn.init();
total_loss = 0;
printf("开始训练...\n\n");
auto start = std::chrono::high_resolution_clock::now();
for (int i = 1; i <= total_batch; i++)
{
train();
if (i % calctme == 0)
{
total_loss /= (float)calctme;
printf("[%.2f%%] 训练了 %d 组样本,平均 loss %f\n", i / (float)total_batch * 100, i * Batch_Size, total_loss);
total_loss = 0;
}
}
auto stop = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(stop - start).count();
brn.save("best.ai");
printf("\n训练完成!共训练 %d ms,模型已保存到 best.ai\n\n",(int)duration);
}
printf("加载测试数据中...\n");
loaddata(testimgpath,testanspath,TEST_T);
printf("加载测试数据完成\n\n");
printf("开始模型评估...\n");
int tot = 0;
for (int i = 1; i <= TEST_T; i ++) tot += test(i);
printf("模型评估完成,正确率:%.2f%%\n\n", (float)tot / TEST_T * 100);
brn.delthis();
system("pause");
return 0;
}
```