WILL

will是一个神经网(深度学习)的通用工具集。

工程地址

https://github.com/scarsty/will

logo

架构

神经网络-神经层架构,没有显式的神经元。

大脑

大脑里面可以包含数个神经网,并控制数据和网络的调度。

神经网

神经网包含数个神经层。这些神经层有固定的计算和依赖顺序,部分网络结构可能有并列的神经层,它们的计算顺序并不一定依赖。

在连接成神经网的时候,会自动去除连接不上的神经层。

神经层

神经层的算法为:正向激活和反向传播。

其中反向传播之前,需要先更新代价函数。

我们首先约定一套符号(在程序中也使用这些符号):

xx表示输入层的输入。

yy表示期望的输出层的输出,即标准答案。

aa表示每层实际上的输出。

bb表示偏置。

以上符号,如无特殊说明,小写是列向量,大写是矩阵。在实际的训练过程中,因为批量训练(MiniBatch)的关系,一个列向量会被扩展成含有nn列的矩阵。但是变换矩阵是不需要扩展的,即如下的事实: W(a1,a2,a3,...,an)=(Wa1,Wa2,Wa3,...,Wan) W(a_1,a_2,a_3,...,a_n)=(Wa_1, Wa_2, Wa_3,...,Wa_n) 乘法默认指矩阵乘,元素乘(或者叫Hadamard乘)用\odot表示。

xxaayybb使用对应的大写,则表示将向量扩展为矩阵。需注意bb的矩阵的每一列都应是完全相同的。

计算顺序

计算顺序为:

  • 正向

  • 以上一层的$A$作为输入更新$X$。如果是输入层,没有这一步。

  • 依据$X$和激活函数更新$A$。

  • 反向

  • 更新$\Delta A$。需要调用下一层的相关函数来更新,因为实际上本层是不知道跟下一层的连接方式的。如果存在多个下层,结果是所有下层返回的和。

  • 更新$\Delta X$。依据激活方式来决定。
  • 更新参数。通常是$W$和$b$,如果有其他的也会更新。首先会计算$\Delta W$和$\Delta b$,之后依据求解器的设定更新$W$和$b$。

神经层种类

每层首先依据上一层的AA计算出本层的XX,之后通过激活函数计算出本层的AA。因此每层内部的XXAA必定同维。

神经层以连接方式分类,包含以下种类:

  • 无连接:没有前一层,仅用于输入层。
  • 全连接:每个神经元跟前面一层的所有神经元都有连接。
  • 卷积:卷积操作。
  • 池化:池化操作。
  • 直连:直接读取上一层的数据,一般是浅复制。通常用于连续两次激活,例如批归一化后再激活。
  • 合并:有多个上层,或者合并,或者求和来计算本层数据。
  • 抽取:提取上一层的一个或者多个通道。

所有层都可以包含激活函数。除了直连层之外,与上层的数据维度通常是不一样的。

所有层都可以有多个下层,这些下层获得的输入是完全一样的。

只有合并层可以有多个上层。

全连接

WW为权重矩阵,行数为本层的神经元数,列数为上层的神经元数。bb为偏置向量,行数为本层神经元数。 al=σ(xl)=σ(Wlal1+b) a^{l}=\sigma(x^l)=\sigma(W^la^{l-1}+b) 全连接必须自己处理XXAA,接收的是上一层的AA,经过线性组合为本层的XX

卷积

如果用向量表示图片,那么实际上卷积可以视为一个线性变换,但是变换矩阵应该是一个稀疏阵。 例如将一个28*28的图片变为24*24,卷积核为5*5,那么对应的矩阵为784*576, 其中有25*576个元素是非零,而且大多数都是重复的。

目前我不确定这样做是否合适,但是明显很耗费内存。

卷积将上一层的AA处理为本层的XX,并加上偏移,激活后为本层的AA

池化

实际上平均值池化可以理解为广义卷积,与上面的讨论类似,也可以用矩阵乘。

池化通常不包含可训练的参数。

激活函数

所有输出和输入维度一致的情况均视为激活函数,而不视为层的种类。因此局域亮度,批归一化均被视为激活函数。

σ\sigma是激活函数,大多数时候用Sigmoid函数,该函数的变量可以为向量或者矩阵,表示对每一个元素进行相同的操作。 a=σ(x)=11+ex a=\sigma(x)=\frac{1}{1+e^{-x}} 除此之外,还有一些其他类型的激活函数。

名称 数学形式
双曲正切 tanh(x)\tanh(x)
Softmax exp(xi)/kexp(xk){\exp(x_i)}/{\sum_{k}{\exp(x_k)}}
ReLU max(0,x)\max(0,x)
Clipped ReLU min(max(0,x),z)\min(\max(0,x),z)

激活函数可以为None,这时一般会做一次浅复制。

附:Softmax反向传播的推导

这里对如何反向推导作一个示范。

从上一层计算得到的是Cost函数CCaa的导数,注意CC是一个数。

那么已知aaxx以及: Ca=(Ca1,Ca2,...,Cak)T \frac{\partial C}{\partial a}=(\frac{\partial C}{\partial a_1},\frac{\partial C}{\partial a_2},...,\frac{\partial C}{\partial a_k})^T 要得到: Cx=(Cx1,Cx2,...,Cxk)T \frac{\partial C}{\partial x}=(\frac{\partial C}{\partial x_1},\frac{\partial C}{\partial x_2},...,\frac{\partial C}{\partial x_k})^T 这里我们以第一项为例: Cx1=i=1kCaiaix1=Ca1a1x1+i=2kCaiaix1 \frac{\partial C}{\partial x_1}=\sum_{i=1}^{k}\frac{\partial C}{\partial a_i}\frac{\partial a_i}{\partial x_1}=\frac{\partial C}{\partial a_1}\frac{\partial a_1}{\partial x_1}+\sum_{i=2}^{k}\frac{\partial C}{\partial a_i}\frac{\partial a_i}{\partial x_1} ai=exij=1kexj a_i=\frac{e^{x_i}}{\sum_{j=1}^{k}e^{x_j}} 代入上式,得到: Cx1=a1(1a1)Ca1i=2ka1aiCai=a1(Ca1i=1kaiCai) \frac{\partial C}{\partial x_1}=a_1(1-a_1)\frac{\partial C}{\partial a_1}-\sum_{i=2}^{k}a_1 a_i\frac{\partial C}{\partial a_i}=a_1(\frac{\partial C}{\partial a_1}-\sum_{i=1}^{k} a_i\frac{\partial C}{\partial a_i}) 即: Δxi=ai(ΔaiaTΔa) \Delta x_i=a_i(\Delta a_i - a^T \Delta a) 类似,对于Softmax-Loss ai=log(exij=1kexj) a_i=\log(\frac{e^{x_i}}{\sum_{j=1}^{k}e^{x_j}}) 可以得到: Δxi=Δaieaij=1kΔai \Delta x_i=\Delta a_i - e^{a_i} \sum_{j=1}^k \Delta a_i

求解器

即如何利用$\Delta W$来更新$W$的方式。

数据类型

浮点数

在Matrix类和所有神经网相关类中,浮点数统一使用real。如果使用模板的话,模板类的规模会过大且难以维护。

real的定义在types.h中,可能为float或者double。

#ifndef _DOUBLE_PRECISION
#define _SINGLE_PRECISION 
typedef float real;
#else 
typedef double real;
#endif

神经网络中主要使用float,没有必要使用double。大部分民用显卡的double的计算能力都比较差。

因为blas这个函数库对float和double调用的是不同名函数,此处用两个类Cublas和Cblas将其重新包装,因此在实际代码中float和double使用的是同名的函数,没有使用模板。

矩阵类

矩阵类的重要作用是包装CUDA运算。所有矩阵基本运算均置于类内部,不属于基本运算的置于MatrixExtend类。后者为纯静态类。

所有矩阵保存的方式都是列优先,与cublas和cblas保持一致。

两种矩阵

构造函数包含数个原型。矩阵有两种形式,即行列矩阵和四阶张量。其中四阶张量的前3个张量在一起视为矩阵的一列。

矩阵的初值是无法确定的,但是保存在显存中的矩阵初值经常都是0。

矩阵可以自己保管数据(Inside),或者有一些矩阵是不自己保管数据,即数据指针不由自己管理,这种矩阵一般用于浅复制。主要影响的是析构和分享指针函数。

在大部分情况下,矩阵会优先在显卡中创建数据指针。但是需要注意的是在输出矩阵之前,需将数据复制到内存。同样,载入矩阵会先载入到内存,再复制到显存。

寻址

按照行列来寻址。注意矩阵中的元素按照行列顺序列出,与直角坐标系相反!

返回的元素大多数是引用。但是注意,引用的地址如果在显存中,是无法赋值和读取的。

并行

采取数据并行。

显存的占用通常包含以下内容:

  • 训练数据,通常会较大。
  • 每个minibatch所占用的显存,贯穿整个网络,通常会较大。
  • 网络参数占用的显存,通常不会特别大。

并行的情况下,需要一个主网络。每次训练都会将网络中的参数取平均值,该过程由主网络完成。各个分网络之后会各自将这部分数据取回。

DataPreparer

该类用来实时生成训练数据,可以根据实际情况增加对应子类。设置位于[data_preparer]块中。

其他

通用类

以下的类有通用性,不依赖公共头文件types.h,与本工程的耦合度较低。

Timer

一个计时器。

Option

用于从ini中提取选项。同时负责转换字串到types.h中的枚举类型。

类的浮点型返回类型是double,如果实际使用的是float则会包含隐式转换。

Random

模板类,可以生成double和float类型的随机数。

特殊功能

以下类用于实现一些特殊功能。

MatrixFiller

用于填充矩阵,有数种填充方法。

CudaToolkit

包含cudnn和cublas的初始化过程和全局变量。

用户任何时候都不应自行创建该类型的对象!对象的总数必定等于系统中安装的设备数,在首次选中某个设备时会进行一次初始化。

所有矩阵都会有一个该类型的指针。

VectorMath

一些基本计算,函数均是模板函数,大部分inline直接使用。用于CPU计算。

DataGroup

包含$X$,$Y$,$A$。表示一组训练数据或验证数据。该类型主要用于简化代码。

will-cuda

不包含于cublas和cuDNN中计算,需要自行撰写。对应包装也放在MatrixExtend类中。

该文件使用宏简化代码,使用需注意。

cuda_hack

用一些手段将cuda高版本的写法转为低版本的写法,用于一些无法升级软件的特殊硬件(例如TK1)。

使用方法

编译

需自行下载opencv的头文件和库文件,放在include和lib目录中。因为占用空间较大并没有加入版本控制。

默认使用Anaconda的python库,用户可能需要手动修改这里的目录配置。

Visual Studio可以直接生成整个工程。

在Linux下使用以下命令即可。其中CXX可以为g++,clang++,icpc等。

注意需要安装libopencv-dev,libopenblas-dev,以及libopenmp等。其中openblas仅在CPU模式下使用。

即使使用GPU进行训练,仍有少量计算需要使用CPU,故此仍然需要openblas或者mkl等。

export CXX=/usr/bin/clang++
cmake .
make -j

命令行

工程会编译得到libwill.lib,will-cuda.lib等库文件,同时可以得到可执行文件will-windows。在linux下也使用这个名字。

usage: will-windows.exe --config=string [options] ...
options:

      --train     train a net
      --test      run test functions
  -c, --config    config file (ini format) to describe the net structure (string)
  -?, --help      print this message

在命令行中使用一个ini文件来指定网络使用的参数:

will-windows -c mnist-cv.ini --train

一个ini文件的示例如下(AlexNet):

[will]
TestDataFile = 
LoadFile = mnist_cv_save2.txt
SaveFile = mnist_cv_save2.txt
trainDatafile =

ExtraTestDataFile = dig.txt

UseMNIST = 1
LoadNet = 1

Batch = 100
WorkType = 0

test_test = 1

TrainEpoches = 20
OutInterval = 100
Testepoch = 1

LearnRateBase = 0.01
weight_decay = 5e-4
Momentum = 0.9

adjustlr = fixed
gamma = 1e-4
power = 0.75

Testmax = 1
USE_CUDA = 1

solver=ada_delta

[layer_in]
type=null
Node = 784
image_width = 28
image_height = 28
next = layer_cv1

[layer_cv1]
type = conv
channel = 50
weight_width=5
needbias=1
active=none
next=layer_pl1
initweight=xavier
normalized_ddeight = 0

[layer_pl1]
type=pool
window_width=2
active=none
next=layer_cv2

[layer_cv2]
type = conv
channel=50
weight_width=5
needbias=1
active=none
next=layer_pl2
initweight=xavier
normalized_ddeight=0

[layer_pl2]
type=pool
window_width=2
active=none
next=layer_full1

[layer_full1]
type = full_connect
Node = 100
active = relu
next = layer_out
initweight=xavier

[layer_out]
type = full_connect
node = 10
active = softmax
initweight=xavier

公共部分

公共部分均写在[will]块中,指定网络使用的一些公共参数,下半部分是网络的结构。注意在选项设置中,除了next项的值之外,其他部分使用大小写,或者有无下划线都是没有影响的。

如果一些选项的参数是多于一个的,则应使用逗号隔开。例如mp_device=0,1

一些重要的参数为:

选项名 说明
train_epoches 训练的epoch数,默认是20次
use_cuda 是否使用cuda设备
mp 使用cuda设备的个数
mp_device 指定使用的cuda设备
learn_rate_base 基础学习率
use_mnist 使用mnist手写数字库,通常用于测试
batch 每批学习的数据量
待补充

如果没有特别指定cuda设备,程序会首先查找可用的设备数,并依据计算能力,空余显存,当前温度以及PCI编号的距离选择一个或者多个较空闲的设备。但是这样选择出来的设备并不一定是最合理,用户有特殊需求时应手动指定。

设备的编号与nvidia-smi所显示的顺序一致。程序运行时建议使用GPU-Z或者类似工具来监视设备的状态。例如:

watch -n 1 nvidia-smi

Windows下如果有此需要,推荐使用WPF来编写一个简单的监视功能,其他GUI程序可能会有闪烁现象。以下代码为范例:

namespace gpustat
{
    /// <summary>
    /// MainWindow.xaml 的交互逻辑
    /// </summary>
    /// 
    public partial class MainWindow : Window
    {
        DispatcherTimer timer;

        public MainWindow()
        {
            InitializeComponent();
            timer = new DispatcherTimer();
            timer.Tick += new EventHandler(timer_Tick);
            timer.Interval = new TimeSpan(0, 0, 1);
            timer.Start();
        }

        private void timer_Tick(object sender, EventArgs e)
        {
            System.Diagnostics.Process pProcess = new System.Diagnostics.Process();
            pProcess.StartInfo.FileName = @"C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe";
            pProcess.StartInfo.UseShellExecute = false;
            pProcess.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden;
            pProcess.StartInfo.RedirectStandardOutput = true;
            pProcess.StartInfo.CreateNoWindow = true;
            pProcess.Start();
            string strOutput = pProcess.StandardOutput.ReadToEnd();
            pProcess.WaitForExit();
            richTextBox.Document.Blocks.Clear();
            richTextBox.Document.Blocks.Add(new Paragraph(new Run(strOutput)));
        }
    }
}

网络设置

所有网络必须以layer_in层开始,以layer_out层结束,不能使用别的名字。中间的层可以使用任意名字,但是不能重复。层间的连接使用next关键字。层的连接方式由type指定。在初始化网络的时候,会检查所有层是否合理,合理的层必须与输入和输出层都有连接(此处注意连接是单向的)。

注意某些设置是首先读取公共部分为默认值,再读取本层设置的。例如基础学习率,求解器类型等。

一个典型的卷积层设置为:

[layer_conv1]
type = conv
active = relu
window_w = 5
window_h = 5
next = layer_pool1

程序会扫描所有layer块,并试图将它们连接。在首次连接之后,会从两侧扫描所有层,去掉与输入层和输出层无连接的层,并再次连接。

但是这样并不能保证得到一个正确的网络,使用者在编写的时候应保证网络部分正确。

Python接口

python接口需要预先安装swig,可以使用build.sh生成。注意vs工程中的路径可能与用户实际的配置不同,应手动修改。will.py和动态库(_will.pyd,_will.so)应放在工作目录下。

一个例子如下:

%matplotlib inline

import matplotlib.pyplot as plt
import numpy as np 
import will
import time
import os

def findMax(matrix, t):
    v=[0]*2
    for i in range(0,2):
        v[i]=matrix.getDataValue(i,t)
    return v.index(max(v)),max(v)

def toImg(matrix, t, m, n):    
    img = np.empty((m,n)) 
    for i in range(0,m):
        for j in range(0,n):
            img[j][i]=matrix.getDataValue(i+j*m,t);
    return img

def toImgC(matrix, t, m, n, c):    
    img = np.empty((m,n,c)) 
    for i in range(0,m):
        for j in range(0,n):
            for k in range(0,c):
                img[j][i][k]=matrix.getDataValue(i+j*m+k*m*n,t);
    return img

def getTime(file):
    return time.ctime(os.stat(file).st_mtime)

def savename(i):
    return 'save%d.txt' % (i%1000)

if 'brain' in dir():
    del brain

ini_file = 'mnist-cv.ini'  
brain = will.Brain()
brain.load(ini_file)
# here you can set options yourself
brain.init()
brain.run()

以上首先定义了一部分常用的功能(例如绘制矩阵中的数据为图片),下面的运行部分先创建一个网络,并运行。

这里有两种方式创建网络,其一为直接通过ini文件,这时除了训练次数之外,与命令行区别不大。这里是可以载入多个ini文件的,如果有相同的项,后面的会覆盖前面的。所以可以将网络写在一个ini文件,公共部分写在另一个ini文件。或者先载入一个专门用来测试的文件(必须要载入网络参数,load_net=1),再载入一个训练用的公共部分(load_net=0覆盖前面的)。

在载入ini之后,初始化之前,可以使用setOptions命令手动修改一些设置参数。当然也可以不载入option,完全手动设置。

例如设置上面提到的卷积层可以这样写:

brain.setOptions('layer_conv1', 'type=conv; output=784; active = relu; window_w=5; window_h=5; next=layer_pool1')

每个选项间用分号隔开。

若设置较多,分几行也是一样的:

brain.setOptions('layer_conv1', 'type=conv; output=784;')
brain.setOptions('layer_conv1', 'active = relu; window_w=5; window_h=5; next=layer_pool1')

公共部分的第一个参数可以为will,或者省略第一个参数(重载),不能写成空字串。

以下为一个绘制图片的例子:

x_matrix = brain.train_data_cpu_.X()
a_matrix = brain.train_data_cpu_.A()
plt.figure(figsize=(15, 75))
k=0
for j in range(0, x_matrix.getCol()):
    c=a_matrix.getDataValue(0,j)
    if c>0.5:
        img = toImg(x_matrix, j, 28, 28)
        ax=plt.subplot(50, 10, k+1)  
        ax.imshow(img, cmap='gray', norm=plt.Normalize(vmin=0,vmax=1))
        ax.text(3, 16, '%d\n%6.4f' % (j+1,c), color='white', family='monospace', fontsize=8)
        ax.set_xticks([])
        ax.set_yticks([])
        k=k+1
        if k >= 500:
            break;

如果需要绘制的矩阵在GPU中,则应首先复制到CPU。例如:

y_matrix = Y.clone(will.MATRIX_DATA_INSIDE, will.CUDA_CPU)

网络可视化

网络的可视化也使用python来制作,需要安装graphviz,并设置相关的环境变量。

以下例程可以绘制出网络的结构:

from graphviz import Graph
def net_show(net):
    connection_types_cl = ['black','green','red','blue','lightblack','orange','pink','lightred']
    connection_types = ['none','fc','conv','pool','direct','corr','combine','extract']
    activation_types = ['none','sigmoid','relu','tanh','clipped_relu','scale','softmax','softmax','softmax','findmax','dropout','recurrent','softplus','lrn','lcn','dn','bn','st']

    layers = []
    layers_type = {}
    layers_colors = {}

    for i in range(0, net.getLayersCount()):
        layer = net.getLayer(i)
        layer.name = layer.getName()
        layer.out_shape = [layer.getOutWidth(), layer.getOutHeight(), layer.getOutChannel()]
        layer.next_layers = []
        layer.layer_type = connection_types[layer.getConnection()]
        layer.layer_color = connection_types_cl[layer.getConnection()]
        layer.label = layer.name + ', ' + activation_types[layer.getActiveFunction()+1] + '\n'+str(layer.out_shape)
        for j in range(0, layer.getNextLayersCount()):
            layer.next_layers.append(layer.getNextLayer(j).getName())
            #print layer.name, layer.out_shape,layer.next_layers,layer.layer_type
        layers_type[layer.name] = layer.layer_type
        layers.append(layer)

    g = Graph('G', filename=ini_file+'.gv')
    g.attr('node',shape='rect',color='lightblue', fontname='Arial', fontsize='12')
    g.body.extend(['rankdir=BU', 'size="20,10"'])
    for i in range(0,len(layers)):
        layer = layers[i]
        g.attr('node',color=layer.layer_color, fontname='Arial')
        g.node(layer.name,label= layer.label )
        #print layer.name
    for i in range(0,len(layers)):
        layer = layers[i]
        for nl in layer.next_layers:
            g.attr('edge', fontname='Arial')
            g.edge(layer.name,nl,label = layers_type[nl] )
    return g

net = brain.getNet()
net_show(net)

用户可以将这段程序放在jupyter notebook的一个单独的块中,方便调整格式。

cuDNN的补充

cuDNN的文档写的比较差,基本是靠蒙。

这里对常用的功能作一些详细说明。

tensor

tensor(张量)是基本的单位,用来描述一组数据的结构。在cuDNN中,最常用的是4阶张量,即图片本身是2维(W*H),同时有C张提取的特征图片,本次输入的数据组为N。

在本工程中,基本只使用4阶NCHW的方式。这里写的顺序与存储的优先级是相反的(待确定),这个可能是fortran的习惯。

packed表示内存连续,即一行紧接着上一行。如果两行之间还有一些无用数据,则不是packed。大部分算法要求NCHW中最后两个是packed的。

部分API的补充

大部分功能可以查看文档。这里是一些补充说明。

cuDNN中使用$y$和$\Delta y$表示输出量,而大部分文献和本工程中,使用的符号是$a$,请注意区别。

cudnnActivationBackward

该函数使用aaΔa\Delta axx来计算Δx\Delta x,公式是这样: Δx=σ(x)Δa \Delta x = \sigma'(x)\odot\Delta a 即该式包含一个对向量作用一个函数和一个元素乘。从表达式中来看yy并没有用到,但是一些情况下使用yy会加快计算的速度。

例如对于Sigmoid函数: a=σ(x)=11+ex a=\sigma(x)=\frac{1}{1+e^{-x}} 其导数为 σ(x)=ex(1+ex)2=a(1a) \sigma'(x)=\frac{e^{-x}}{(1+e^{-x})^2}=a(1-a) 可见使用aa时避免了指数计算,速度应更快。

cudnnSetOpTensorDescriptor

cudnnSetOpTensorDescriptor文档中干脆没写。原型如下:

cudnnStatus_t CUDNNWINAPI cudnnSetOpTensorDescriptor(
                                cudnnOpTensorDescriptor_t           opTensorDesc,
                                cudnnOpTensorOp_t                   opTensorOp,
                                cudnnDataType_t                     opTensorCompType,
                                cudnnNanPropagation_t               opTensorNanOpt );

熟悉cudnn接口的话,可以很容易理解其中的含义。

一个典型用法是:

cudnnSetOpTensorDescriptor(od, CUDNN_OP_TENSOR_MUL, CUDNN_DATA_DOUBLE, CUDNN_NOT_PROPAGATE_NAN);

该函数可以用来进行元素乘,两个向量(矩阵)相减等DNN中常见的操作。

cuda相关

numBlocks, threadsPerBlock的选取

依据下图:

所以看着办吧。

MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);

threadsPerBlock目前最多是1024。

从kepler架构来看没这么简单。先不管了。

results matching ""

    No results matching ""