2.4 DNN引擎层
DNN引擎层对上实现了API层,包括API层常用函数的实现、Layer类和Net类基础类型的实现,对下则提供一个架构,将Layer中的具体计算交给引擎加速层来完成。本节将首先介绍如何导入来自不同深度学习框架的模型,这些不同格式的模型最终会转化成DNN引擎定义的Net和Layer数据结构;然后以数据对象为切入点介绍DNN引擎的工作过程,其中包括网络在运行期的一些优化技术和典型层类型讲解。
2.4.1 模型导入
2.3.3节讲述了可以用readNet来加载各种深度学习框架的模型,根据输入参数的不同,会在内部调用相应的模型加载函数,源代码逻辑非常简单,如下所示。
1 Net readNet(const String& _model, const String& _config, const String& _framework) 2 { 3 String framework=toLowerCase(_framework); 4 String model=_model; 5 String config=_config; 6 const std::string modelExt=model.substr(model.rfind('.') + 1); 7 const std::string configExt=config.substr(config.rfind('.') + 1); 8 if (framework=="caffe" || modelExt=="caffemodel" || configExt=="caffemodel" || 9 modelExt=="prototxt" || configExt=="prototxt") 10 { 11 if (modelExt=="prototxt" || configExt=="caffemodel") 12 std::swap(model, config); 13 return readNetFromCaffe(config, model); 14 } 15 if (framework=="tensorflow"|| modelExt=="pb" ||configExt=="pb" || 16 modelExt=="pbtxt" || configExt=="pbtxt") 17 { 18 if (modelExt=="pbtxt" || configExt=="pb") 19 std::swap(model, config); 20 return readNetFromTensorflow(model, config); 21 } 22 if (framework=="torch" || modelExt=="t7" || modelExt=="net" || 23 configExt=="t7" || configExt=="net") 24 { 25 return readNetFromTorch(model.empty() ? config : model); 26 } 27 if (framework=="darknet" || modelExt=="weights" || configExt=="weights" || 28 modelExt=="cfg" || configExt=="cfg") 29 { 30 if (modelExt=="cfg" || configExt=="weights") 31 std::swap(model, config); 32 return readNetFromDarknet(config, model); 33 } 34 if (framework=="dldt" || modelExt=="bin" || configExt=="bin" || 35 modelExt=="xml" || configExt=="xml") 36 { 37 if (modelExt=="xml" || configExt=="bin") 38 std::swap(model, config); 39 return readNetFromModelOptimizer(config, model); 40 } 41 if (framework=="onnx" || modelExt=="onnx") 42 { 43 return readNetFromONNX(model); 44 } 45 CV_Error(Error::StsError, "Cannot determine an origin framework of files: " + 46 model + (config.empty() ? "" : ", " + config)); 47 }
将上述代码整理为表格,如表2-1所示。
表2-1 DNN模块支持的深度学习框架及相应的模型加载函数
其中framework参数列中的dldt指的是Deep Learning Deployment Toolkit,也就是Intel公司推出的OpenVINO软件包。因为dldt加载的网络模型是经过OpenVINO软件包中的模型优化器(ModelOptimizer)组件处理后的输出,所以对应的处理函数中有FromModelOptimizer字样。这个函数最终将调用Net类中的成员函数Net::readFromModelOptimizer。
为了方便开发者使用,开发者调用readNet()时无须关心model参数和config参数的放置顺序,framework参数也无须特别指定。readNet()函数内部会自动推断model参数和config参数哪个在前哪个在后,然后根据model和config推断framework类型。
从磁盘上的模型文件到内存表示,再到OpenCV的内部模型表示(即Net类和Layer类等对象的组合),所有的模型加载函数都是一样的过程,只是因为具体格式的不同而处理代码也不同。具有共性的是ONNX、Caffe和TensorFlow的模型文件,它们都是用Protobuf来保存的。Protobuf适合数据存储、数据交换等场合,如图2-3所示,结构化数据可能是一个结构体的实例(以C++为例,后续的讲述均以C++为例),如果为每个结构体都实现相应的序列化函数和反序列化函数,则工作量非常大,而且结构体本身还可以发生增减变化,就算实现了,效率也不一定好。Protobuf通过元编程的思想,巧妙地解决了这个问题,使得任何结构体的序列化函数和反序列化函数都可以自动生成。
使用Protobuf需要其提供的3部分内容,分别是编译器protoc、头文件和库文件。可以从Protobuf源代码开始,编译后将这3部分内容安装到系统中。安装完成后,可以直接执行protoc命令,还可以用pkg-config命令来查询Protobuf对应的头文件和库文件。
图2-3 结构化数据存储和交换示意图
1)安装依赖库:
$ apt-get install autoconf automake libtool curl make g++ unzip
2)获取源代码:
$ git clone https://github.com/protocolbuffers/protobuf.git $ cd protobuf $ git submodule update --init --recursive $ ./autogen.sh
3)编译C++版本的Protobuf:
$ ./autogen.sh $ ./configure $ make
4)再做一些检查,如果这个步骤发生错误,则只是表示某些功能无法使用,Protobuf的主要功能还是可以正常使用的:
$ make check
5)安装到系统:
$ sudo make install $ sudo ldconfig
6)对安装文件做一个简单的检查:
# protoc --version libprotoc 3.11.0 # pkg-config --cflags --libs protobuf -pthread -I/usr/local/include -L/usr/local/lib -lprotobuf
Protobuf提供了一种描述结构体的方法,此描述的文件扩展名一般是.proto。例如,下面的代码定义了一个结构体:
struct ABC { int32 ii; float ff; };
而这个结构体可以用文件abc.proto表示,内容如下:
syntax="proto2"; package mytry; message ABC { optional int32 ii=1; optional float ff=2; }
其中,optional表示这个属性是可选的,是基于以后Struct数据成员增减兼容目的而引入的;而尾部的=1和=2,是给每个数据成员属性赋予一个唯一的标识号,在Protobuf内部的编解码实现过程中会用到,在此不展开讲解。然后,使用protoc编译器执行命令“protoc--cpp_out=.abc.proto”,就会在当前目录下生成两个新文件,即abc.pb.h和abc.pb.cc文件,其中abc.pb.h的关键内容如下:
namespace mytry { class ABC :public ::PROTOBUF_NAMESPACE_ID::Messag{ Public: bool has_ii() const; ::PROTOBUF_NAMESPACE_ID::int32 ii() const; void set_ii(::PROTOBUF_NAMESPACE_ID::int32 value); float ff() const; void set_ff(float value); private: ::PROTOBUF_NAMESPACE_ID::int32 ii_; float ff_; }; }
从以上代码可以看出,struct ABC中的数据成员在class ABC中继续存在,而且class ABC进行封装做了访问限制,使用成员函数的方法来获取数据成员,这样,原来程序需要用到struct ABC的地方都改用mytry::ABC即可,原有代码不需要做其他变化即可继续使用。另外,class ABC继承了::PROTOBUF_NAMESPACE_ID::Messag,而Message又继承了MessageLite,这个类在Protobuf头文件中定义,提供了序列化函数和反序列化函数。
class PROTOBUF_EXPORT MessageLite { bool ParseFromIstream(std::istream* input); bool SerializeToOstream(std::ostream* output) const; };
将自动生成的这两个源文件加入我们的项目中,在编译、连接项目时增加'pkg-config--cflags--libs protobuf'的选项,在运行项目可执行程序的时候,还要确保Protobuf的库文件在系统中。使用Protobuf的完整流程如图2-4所示,其中写程序和读程序可以是同一个,也可以是不同的。
回到OpenCV源代码树,这是一个读取模型文件的过程,即反序列化的过程。我们可以在modules/dnn/src/caffe、modules/dnn/src/onnx和modules/dnn/src/tensorflow中找到相应的.proto文件,可以在modules/dnn/misc/下的相应目录中找到对应的.pb.h和.pb.cc文件,这是因为开发者提前在本机用protoc编译器将.proto文件转换成了.pb.h/cc文件,然后将这些文件直接作为OpenCV源代码的一部分。
编译OpenCV和运行OpenCV的深度学习相关模块,都需要依赖Protobuf库文件,编译和运行会发生在不同机器系统上,而不同系统安装的库文件版本可能并不相同,不兼容问题会导致运行时OpenCV加载模型出错。为了解决这个问题,OpenCV将Protobuf源代码也加到3rdparty/protobuf目录下,这个目录下的文件CMakeLists.txt中有如下代码:
图2-4 使用Protobuf的完整流程
add_library(libprotobuf STATIC ${Protobuf_SRCS})
它表示将Protobuf的源代码编译为一个静态库,名字为liblibprotobuf.a,这个静态库将被包含到库文件libopencv_dnn.so中。所以,OpenCV的编译和运行所需要的Protobuf支持是来自同一份源代码,而且已经被集成到了libopencv_dnn.so中,而不会使用系统中安装的Protobuf动态库文件,因此解决了可能存在的不匹配导致模型加载出错的问题。
如果对OpenCV如何加载模型文件有更多兴趣的话,则通过下面的代码,然后单步调试一步步地进去可以看到更多的细节,其中tf.pb文件是TensorFlow的模型文件,如何生成可以参考7.2.1节。
#include <stdio.h> #include <opencv2/opencv.hpp> #include <opencv2/dnn.hpp> using namespace cv; using namespace dnn; int main() { Net net=readNet("tf.pb", "", "tensorflow"); }
[1] Protobuf即Protocol Buffers,是由Google公司提出的用于结构化数据序列化的机制,支持C++、Java和Python等多种语言,适合数据存储、数据交换等场合。
2.4.2 推理引擎数据对象管理
神经网络运算中需要用到大量的内存来存储模型参数、每一层的输入/输出及内部数据。DNN模块通过分析每一层数据的生命周期,尽可能地复用前面层所分配的内存,从而大大节省神经网络运行时的整体内存消耗。DNN模块的运算数据称为blob(张量或数据对象),blob实际上是一个Mat对象。DNN模块在allocateLayers()函数中为神经网络的每一层分配合适大小的Mat对象,下面分段讲解关键代码。
第2293~2306行,代码如下:
2293 ShapesVec inputShapes; 2294 for(int i=0; i < layers[0].outputBlobs.size(); i++) 2295 { 2296 Mat& inp=layers[0].outputBlobs[i]; 2297 CV_Assert(inp.total()); 2298 if (preferableBackend==DNN_BACKEND_OPENCV && 2299 preferableTarget==DNN_TARGET_OPENCL_FP16) 2300 { 2301 layers[0].outputBlobs[i].create(inp.dims, inp.size, CV_16S); 2302 } 2303 inputShapes.push_back(shape(inp)); 2304 } 2305 LayersShapesMap layersShapes; 2306 getLayersShapes(inputShapes, layersShapes);
上面这段代码的作用是从整个网络的输入开始,对每一层,根据输入数据的内存布局(inputShapes)计算输出数据和中间数据的内存布局(layersShapes)。内部具体调用的是Layer::getMemoryShapes()函数,该函数需要每个层类型根据自身运算特点单独实现。
第2311~2322行,为需要保留的blob和每一层的输入设置引用计数,为后续存储空间优化做准备:
2311 for (int i=0; i < layers[0].outputBlobs.size(); ++i) 2312 blobManager.addReference(LayerPin(0, i)); 2313 for (it=layers.begin(); it !=layers.end(); ++it) 2314 { 2315 const LayerData& ld=it->second; 2316 blobManager.addReferences(ld.inputBlobsId); 2317 } 2318 2319 for (int i=0; i < blobsToKeep_.size(); i++) 2320 { 2321 blobManager.addReference(blobsToKeep_[i]); 2322 }
第2324~2328行,调用allocateLayer()为每一层分配输出和中间数据内存,代码如下:
2324 for (it=layers.begin(); it !=layers.end(); it++) 2325 { 2326 int lid=it->first; 2327 allocateLayer(lid, layersShapes); 2328 }
接下来详细讲解allocateLayer()函数。在讲解代码之前,先大体说明一下神经网络计算中数据存储对象分配和引用的一般规则,方便理解后续代码。如图2-5所示(为简便起见没有画出层内部数据对象分配),神经网络的每一层是前后相连的,前面层的输出是后面层的输入,所以每一层只需要分配输出数据对象和内部数据对象,后一层的输入直接通过指针引用前一层的输出。
图2-5 数据对象分配和引用
第1859~1860行,递归调用allocateLayer()自身,确保当前层之前的所有层都已经分配好内存:
1859 for (set<int>::iterator i=ld.inputLayersId.begin(); i !=ld.inputLayersId.end(); i++) 1860 allocateLayer(*i, layersShapes);
第1873~1884行,当前层引用前一层的输出作为本层的输入:
1873 { 1874 ld.inputBlobs.resize(ninputs); 1875 ld.inputBlobsWrappers.resize(ninputs); 1876 for (size_t i=0; i < ninputs; i++) 1877 { 1878 LayerPin from=ld.inputBlobsId[i]; 1879 CV_Assert(from.valid()); 1880 CV_DbgAssert(layers.count(from.lid) && (int)layers [from.lid].outputBlobs.size() > from.oid); 1881 ld.inputBlobs[i]=&layers[from.lid].outputBlobs[from.oid]; 1882 ld.inputBlobsWrappers[i]=layers[from.lid]. outputBlobsWrappers[from.oid]; 1883 } 1884 }
第1891~1893行,调用BlobManager::allocateBlobsForLayer()函数为输出数据和内部数据分配内存。我们会在allocateLayer()函数讲解结束之后接着讲解BlobManager::allocateBlobsForLayer()函数。
1891 blobManager.allocateBlobsForLayer(ld, layerShapesIt->second, pinsForInternalBlobs, 1892 preferableBackend==DNN_BACKEND_OPENCV && 1893 preferableTarget==DNN_TARGET_OPENCL_FP16);
第1894~1903行,将输出数据和内部数据封装到后端设备数据对象。DNN模块定义了抽象类BackendWrapper来封装除CPU之外的其他运算设备上的数据对象,不同加速后端需要实现具体的封装类型(2.5节会对各种加速后端进行介绍,第4章至第6章则会对加速后端的实现进行详细讲解)。
1894 ld.outputBlobsWrappers.resize(ld.outputBlobs.size()); 1895 for (int i=0; i < ld.outputBlobs.size(); ++i) 1896 { 1897 ld.outputBlobsWrappers[i]=wrap(ld.outputBlobs[i]); 1898 } 1899 ld.internalBlobsWrappers.resize(ld.internals.size()); 1900 for (int i=0; i < ld.internals.size(); ++i) 1901 { 1902 ld.internalBlobsWrappers[i]=wrap(ld.internals[i]); 1903 }
第1905~1923行,调用Layer::finalize()。这个函数是一个虚函数,由每个层类型具体实现。在这个时点上,层的参数已经知道,输入、输出和内部数据对象也已经分配完成,但前向运算尚未开始。可以利用这个时点,做一些优化的事情。例如,convolution层会在这个函数中对权重矩阵进行内存对齐处理,方便后续的CPU汇编指令优化。
1905 Ptr<Layer> layerPtr=ld.getLayerInstance(); 1906 { 1907 std::vector<Mat> inps(ld.inputBlobs.size()); 1908 for (int i=0; i < ld.inputBlobs.size(); ++i) 1909 { 1910 inps[i]=*ld.inputBlobs[i]; 1911 } 1912 layerPtr->finalize(inps, ld.outputBlobs); 1913 layerPtr->preferableTarget=preferableTarget; // 这里省略了部分无用代码 1923 }
第1926~1927行,释放输入数据对象和内部数据对象的引用计数,之前在allocateLayers()代码的2311~2322行,增加了输入数据对象的引用计数,此处释放意味着该层的输入数据对象可以为后续层复用。
1926 blobManager.releaseReferences(ld.inputBlobsId); 1927 blobManager.releaseReferences(pinsForInternalBlobs);
第1929行,设置标志为1,说明该层已经完成数据分配。
1929 ld.flag=1;
至此,allocateLayer()函数解析完毕,下面对其调用的BlobManager::allocateBlobsForLayer()函数做进一步解释,因为该函数包含了数据对象分配和复用的核心逻辑。
BlobManager::allocateBlobsForLayer()函数首先会尝试采用inpalce方式,代码段如下:
904 bool inPlace=false; 905 if (layerShapes.supportInPlace) 906 { 907 if (ld.inputBlobs.size()==1) 908 { 909 // Get number of references to the input memory. 910 int numRef=numReferences(ld.inputBlobsId[0]); 911 // If current layer is one and only customer of this blob. 912 inPlace=numRef==1; 913 } 914 }
inplace复用方式如图2-6所示。
图2-6 inplace复用方式
inplace复用的判断过程如下:如果层2支持inplace运算(inplace运算是指每个输出数据可以直接覆盖输入数据而不影响下一个输出数据的计算),且只有一个输入,并且输入数据当前的引用计数为1(意味着只有当前层在用),那么层2无须分配输出内存,直接引用输入内存即可。DNN模块中所有按元素操作的层(elementwise layer),如ReLU、Power都属于这一类。
BlobManager::allocateBlobsForLayer()函数中更一般的数据对象复用方法是基于引用计数的跨层复用,在BlobManager::reuseOrCreate()函数中实现。关键代码段:
// 在for循环中遍历所有的已分配数据对象 851 for (hostIt=memHosts.begin(); hostIt !=memHosts.end(); ++hostIt) 852 { 853 refIt=refCounter.find(hostIt->first); 854 // Use only blobs that had references before because if not, 855 // it might be used as output. // 856行,如果引用计数为0,则说明该数据对象已空闲,可以被复用 856 if (refIt !=refCounter.end() && refIt->second==0) 857 { 858 Mat& unusedBlob=hostIt->second; // 859~860行找到符合要求的最小数据对象 859 if (unusedBlob.total() >=targetTotal && 860 unusedBlob.total() < bestBlobTotal) 861 { 862 bestBlobPin=hostIt->first; 863 bestBlob=unusedBlob; 864 bestBlobTotal=unusedBlob.total(); 865 } 866 } 867 }
图2-7形象地展示了复用算法:由于DNN运算是单线程顺序进行的,这意味着同一时刻只有一个层运算在进行。在层3进行运算时,层1和层2的运算已经结束,层1的输出(即层2的输入)不会再被用到,因此这块内存可供后续层使用。此例中,层3复用了层1的输出数据对象而无须自己分配。
图2-7 基于引用计数的数据对象复用
[1] 完整代码参见https://github.com/opencv/opencv/blob/4.1.0/modules/dnn/src/dnn.cpp#L2284。
[2] 完整代码参见https://github.com/opencv/opencv/blob/4.1.0/modules/dnn/src/dnn.cpp#L1825。
2.4.3 推理引擎重点层解释
在深度神经网络中,每一层对应一个运算类型,多个层连在一起还可以进行合并优化。本节将介绍几个主要的运算类型:卷积、激活(如ReLU)、池化和全连接。
1.卷积运算
卷积是深度学习中最重要的奠基性操作之一,究其本质,其和传统数字图像处理中挖掘图像局部特征的算子一脉相承,就是一个过滤器(filter),也称作kernel。kernel本质上是一组参数值(也称为权重值),确定了这些参数值,就确定了卷积计算。在传统数字图像处理中,参数值一般是专家经过千锤百炼的尝试之后才找到的,而在卷积神经网络中,参数值则是基于样本数据进行有监督学习后,用机器学习的方法自动学习得到的。所以,接下来我们先从传统算子出发,探讨卷积的本质含义,再介绍卷积在深度学习中的发展。
不妨以Sobel算子为例,Sobel算子包括水平方向和垂直方向,由于一个方向就可以说明问题,所以下面的OpenCV代码只用了Sobel算子水平方向的特性。
#include <stdio.h> #include <opencv2/opencv.hpp> using namespace cv; int main(int argc, char** argv) { Mat image; image=imread(argv[1], IMREAD_GRAYSCALE); if ( !image.data ) { printf("No image data \n"); return -1; } imwrite("gray.bmp", image); imshow("original gray", image); Mat sobel; Sobel(image, sobel, CV_8U, 1, 0, 3, 1, 1, BORDER_REPLICATE); imwrite("sobel.bmp", sobel); imshow("sobel", sobel); waitKey(0); return 0; }
其中,Sobel的函数原型如下:
void cv::Sobel(InputArray _src, OutputArray _dst, int ddepth, int dx, int dy,int ksize, double scale, double delta, int borderType )
在代码中,ksize=3表示kernel size是3×3,而dx=0,dy=1则表示使用水平方向的算子,具体kernel数值如图2-8a所示;而如果dx=1,dy=0,则对应的是竖直方向的算子,具体kernel数值如图2-8b所示。
图2-8 Sobel算子的3×3 kernel的数值
另外,函数参数scale=1,delta=1表示对计算结果做*1+1的调整(这部分将在后面具体介绍),而borderType=BORDER_REPLICATE则用于处理图像边界情况(这部分也会在后面具体介绍)。
运行这个程序,以图2-9图片作为输入。
图2-9 Sobel函数输入图片
最终的输出如图2-10所示,可以看出,水平方向的边缘基本分辨出来了。
图2-10 Sobel函数输出图片
为了进一步理解Sobel水平算子背后发生了什么,我们设定特别的输入值,再查看相应的输出数值,以建立它们之间的联系。
代码一开始如下所示,主要包含头文件和main函数的开始。
#include <stdio.h> #include <opencv2/opencv.hpp> using namespace cv; int main(int argc, char** argv) { Mat image;
然后,以灰度形式读入图像数据:
image=imread(argv[1], IMREAD_GRAYSCALE); if ( !image.data ) { printf("No image data \n"); return -1; } for (int i=0; i < 4; ++i) { image.at<uchar>(0,i)=i + i % 3 + 1; image.at<uchar>(1,i)=i * 5 + i % 2 + 2; image.at<uchar>(2,i)=i * 9 + i % 3; image.at<uchar>(3,i)=i * 17 + 7; }
下面代码试着显示刚刚修改过左上角数值的灰度图:
imwrite("gray.bmp", image); imshow("original gray", image);
接下来做Sobel处理,并且将处理前后图片的左上角数值输出:
Mat sobel; Sobel(image, sobel, CV_8U, 1, 0, 3, 1, 1, BORDER_REPLICATE); printf("input gray image top left corner:"); for (int j=0; j < 4; ++j) { printf("%d %d %d %d\n", image.at<uchar>(j, 0), image.at<uchar>(j, 1), image.at<uchar>(j, 2), image.at<uchar>(j, 3)); } printf("\nresult top left corner:"); for (int j=0; j < 3; ++j) { printf("%d %d %d\n", sobel.at<uchar>(j, 0), sobel.at<uchar>(j, 1), sobel.at<uchar>(j, 2)); }
下面把Sobel处理后的图像存储到sobel.bmp,并且把图像显示出来。
imwrite("sobel.bmp", sobel); imshow("sobel", sobel); waitKey(0); return 0; }
运行上述程序,可以看到如下输出:
Input gray image top left corner: 1 3 5 4 2 8 12 18 0 10 20 27 7 24 41 58 result top left corner: 13 23 14 25 45 39 44 85 79
我们输出了作为Sobel算子输入的灰度图像的左上角4×4的16个像素值,如图2-11所示。
也输出了Sobel算子输出的灰度图像的左上角3×3的9个像素值,将这些数据整理排列后如图2-12所示。
因为我们这里设置的Sobel算子的kernel size为3×3,所以输入图像中3×3个像素点,对应着输出图像中的1个像素点。例如,在输出图像坐标(1,1)处的值是45,就是通过对输入图像左上角粗线矩形区域中的3×3像素,结合Soble算子水平方向的3×3 kernel数值的运算结果。
图2-11 Sobel函数输入数据
图2-12 Sobel函数输出数据
图2-13 Sobel函数运算过程
如图2-13所示,即1×(-1)+3×0+5×1+2×(-2)+8×0+12×2+0×(-1)+10×0+20×1=44。
从数学角度来看,可以认为此结果是两个矩阵相应元素相乘的和,此即卷积的最基本形式。再考虑代码中Sobel函数的参数,可得到最后结果:44*scale+delta=44×1+1=45。
将图2-13中的黑色粗线方框在输入图像中依次左、右、上、下逐像素滑动,针对每次滑动,都和算子的kernel数值做一次运算,其结果为输出图像中的一个像素值,滑动完成后可以得到输出图像的所有像素值。这里还需要考虑的是,输出图像边缘处的数值如何得到,因为此时粗线方框的一部分区域在图像之外,这部分在外区域用什么数值呢?这就是borderType的意义所在,其参数值决定了在外区域要填补的数值,然后正常运算即可。
回过头来,考虑Soble水平算子为什么可以检测到图中竖直的边界线呢。从图2-13的运算过程可以看出,假如某个像素左右两侧像素的灰度值相差越大,那么运算结果绝对值也越大;如果左右两侧像素的灰度值非常接近,那么运算结果就会接近于0。在输出图片中,运算结果在0附近的以黑色呈现,而较大的数值在输出图中以灰白色呈现,自然地就构成了图中的白色边界线。
至此,我们已经明白了在传统数字图像处理中卷积的含义,如果用二维矩阵P来表示输入图像的像素值,用二维矩阵Q来表示输出图像的像素值,用二维矩阵W来表示算子的kernel参数,不考虑Sobel函数中额外的scale和delta值,用来表示卷积过程,我们可以得到如下数学公式:
PW=Q (2-1)
其中,矩阵P和Q的行列数相同(为讨论简单,这里暂时只考虑相同的情况,不同的情况将在后面讲述),对应着输入图像和输出图像的尺寸,即输入图像和输出图像的尺寸是相同的。而W的行数和列数对应着卷积核,一般是远小于矩阵P的行数和列数,是一个小矩阵。一般来说,W是一个方阵,所以其中的数值有kernel_size*kernel_size个。
在卷积神经网络中,W不再由专家事先研究给出,而是通过机器学习的方法,通过大量的样本学习得到。毕竟,人类专家能够给出的W数量终究很少,而万事万物需要的W各不相同,需要大量的W以适用于不同的任务,现代机器学习方法较好地解决了这个问题,可根据具体的任务(数据集)学到最佳的W,从而更好地获取图片特征,为深度学习打下最坚实的基础。
为什么可以从样本学到W呢?这里做一个最简单的思路介绍。一旦样本确定,其中的输入P和输出Q就变成了常量,W成了要求解的未知量,loss fuction(损失函数)成为W的函数,不妨记为f(W),我们的目标就变成了求解方程f(W)=0,这就可以用数值计算方法中的梯度下降法来求解了(在神经网络中的传统做法是SGD,即随机梯度下降法)。如果W是一个标量,那么就是牛顿迭代法,每次迭代w都向导数的相反方向做一个变化。而对于多变量W,每次迭代时,W向梯度的相反方向做一个变化即可。当然,具体变化多大,涉及学习速率等训练时要考虑的超参数选择的问题。这也是深度神经网络学习所有模型参数的基本思想。
卷积层的输入图像并不限定于灰度图,也就是说,可能是具有红、绿、蓝三通道的彩色图片,那么,式(2-1)可以扩展为:
P1W1+P2W2+P3W3+b=Q (2-2)
其中,二维矩阵P1对应红色分量,二维矩阵P2对应绿色分量,二维矩阵P3对应蓝色分量;而3个小矩阵W1、W2和W3则分别对应红、绿、蓝3个分量进行卷积的kernel参数。
在式(2-2)中,符号的意义和式(2-1)中的完全相同,前两个加号是矩阵间对应元素的相加,最后一个加号是矩阵中每个元素都加上一个标量b。分别对每个通道分量进行卷积,然后将所有卷积结果矩阵的对应元素相加,最后为结果矩阵中的每个元素都加上标量b,得到最终结果,这就是深度学习卷积层操作的最基本定义。
为什么需要增加偏差b?是因为累加后数值可能会过大或者过小,通过增加偏差b使得数值回归,当使用sigmoid函数作为激活函数的时候,这么做有较大的作用。其思路和Sobel函数中的delta参数是一致的,在深度学习卷积层操作中,对应着bias的概念。随着多种不同激活函数的应用,有时候不再需要bias。
式(2-2)还可以进一步地简单表示为
(P1,P2,P3)(W1,W2,W3,b)=Q (2-3)
在深度学习中,将(W1,W2,W3,b)称为一个滤波器(filter),其参数值一共有kernel_size*kernel_size*3+1个,因为有3个Wi矩阵,每个Wi矩阵的行列都是kernel_size。所以,式(2-3)还可以再进一步地简单表示为
(P1,P2,P3)filter=Q (2-4)
式(2-4)可用图2-14所示示意图表示。
图2-14 三通道卷积过程
在深度学习中,二维矩阵Q称为特征图(feature map),通过filter运算可以得到一张特征图,在解决实际问题时,一个filter远远不够,因此,需要支持多个filter以生成多个特征图,假如使用两个filter,则可以用下面的数学公式来表示:
(P1,P2,P3)(filter1,filter2)=(Q1,Q2) (2-5)
展开表示为
(P1,P2,P3)filter1=Q1 (2-6)
(P1,P2,P3)filter2=Q2 (2-7)
实际上,卷积层的输出可以作为下个卷积层的输入,因此,卷积输入并不限于灰度这1个通道或者红、绿、蓝3个通道,完全可以是多个通道,所以,式(2-5)更完整的数学表达是
(P1,P2,…,Pm)(filter_1,filter_2,…,filter_n)=(Q1,Q2,…,Qn) (2-8)
其中,作为机器学习目标的所有filter参数值的个数,和P矩阵的行列数目无关,也和Q矩阵的行列数目无关,其个数为kernel_size*kernel_size*m*n+需要bias?n:0(其中,m是矩阵P的个数,n是矩阵Q的个数)。所以,在卷积层操作中,网络模型和输入图像的尺寸无关,即可以处理任意尺寸的图像。这就是很多深度学习算法无须为不同尺寸的输入图像提供不同网络模型的基础所在。
式(2-8)对应的卷积层运算示意图如图2-15所示。
图2-15 卷积层运算示意图
接下来讨论卷积层的可选设置,包括stride、padding、dilation和data_format。了解后即可理解keras.layers.Conv2D中相关函数参数的意义。Keras是一个被广泛使用的开源Python深度学习库,已被集成到TenserFlow中。keras.layers.Conv2D原型如下:
keras.layers.Conv2D(filters, kernel_size, strides=(1, 1), padding='valid', data_format=None, dilation_rate=(1, 1), activation=None, use_bias=True, kernel_initializer='glorot_uniform', bias_initializer='zeros', kernel_regularizer=None, bias_regularizer=None, activity_regularizer=None, kernel_constraint=None, bias_constraint=None)
之前提到,粗线方框逐像素地向上、下、左、右滑动,此时对应的strides为(1,1),如果粗线方框每次滑动时向右移动2像素,或者向下移动3像素,那么对应的strides就是(2,3)。之前还提到,当粗线方框在图像的边缘处滑动时,粗线方框的部分区域会落在图像之外。这种情况的处理由padding参数决定:如果padding参数是same,那么落在图像之外的区域用0来填充;如果padding参数是valid,那么直接忽略这种情况,即这种部分方框落在图像之外的情况,不产生卷积输出,此时输出矩阵Q的行列数就会小于输入矩阵P的行列数,也就是说,输出矩阵Q中每个元素的数值都来自输入矩阵的正常元素,而没有额外添加填充元素,即所谓的有效(valid)。
综合stride参数和padding参数,以及filter的kernel_size,假如输入矩阵的行数为size(列数也是相同的算法),则输出矩阵的行数如下。
·padding参数是same:ceil(size/stride),其中ceil为天花板函数,向上取整。只要存在像素没有被stride除尽,就可以通过补0达到卷积的尺寸,因此是向上取整。当stride为1时,输入矩阵的行数与输出矩阵的行数相同,这是same的含义所在。
·padding参数是valid:ceil((size-kernel_size+1)/stride),其中ceil为天花板函数,向上取整。因为size先行减去了(kernel_size-1),因此,只要存在像素没有被stride除尽,加回之前被减去的(kernel_size-1),就可以构成一个完整有效的卷积区域,因此,也是向上取整。
kernel_size决定了输入矩阵中多少元素参与单个卷积过程。如果kernel_size过大,会导致kernel参数的个数过多,从训练角度来说,这增加了训练的复杂性,要求有更多的训练样本,而且,参数更多的模型的泛化能力往往相对较弱。如果kernel_size过小,则输入矩阵中参与单次卷积的数据区域范围过小,使得输出结果难以提取恰当特征,这可以由多个卷积层用dilation的概念来一起巧妙解决。如图2-16a所示,9个元素值紧凑地排在一起,此时dilation_rate为1;假如dilation_rate为2,如图2-16b所示,可以将filter理解为5×5的尺寸,9个元素值处于“*”号处,中间无“*”号的值可以理解为0。
图2-16 dilation_rate作用示意
采用这个方法,在filter参数个数保持不变的情况下,更大范围的像素值被考虑被卷积,即拥有了更大的感受野(在卷积输出矩阵中,任意一个元素的值,和原始输入图像的哪个区域的像素值相关,这个区域即称为感受野)。
最后,我们看一下卷积层的输入/输出数据在内存的布局,在Caffe中,默认布局是<N,C,H,W>,而在TensorFlow中,默认布局是<N,H,W,C>。其中,N是number,在推理时候不考虑并行加快速度的时候,一般用1;H是height,W是width,对应式(2-8)中矩阵Pi和Qj的行数和列数;C是channel,即矩阵Pi和Qj的个数。举例来说,要表示4张1920×1080的彩色(红、绿、蓝)图像,则N=4,H=1080,W=1920,C=3。内存数据本质上是依次排列的一维数组,第一个数据对应着(N=0,H=0,W=0,C=0)的数值,那第二个数据呢?在<N,H,W,C>布局下,第二个数据对应着(N=0,H=0,W=0,C=1)的数值,就相当于彩色图像中每个像素的颜色三通道的数据是依次放在一起的;而在<N,C,H,W>布局下,第二个数据对应着(N=0,C=0,H=0,W=1)的数值,就相当于我们将彩色图像的R、G、B 3个通道分拆,先存储该图像的R通道的全部数据,再存放G通道的全部数据,然后存放B通道的所有数据。所以,data format的取值可能是channels_last(即NHWC)或者channels_first(即NCHW),对应着channel在数据结构中的不同位置。
卷积在深度学习中有很多变形,其中一个很重要的变形是深度可分离卷积(depthwise separable convolution),即将一个完整的卷积运算分解为两步进行,即Depthwise Convolution与Pointwise Convolution。Depthwise Convolution的一个卷积核负责一个通道,一个通道只被一个卷积核卷积,如图2-17所示。Depthwise Convolution完成后的特征图数量与输入层的通道数相同,无法扩展特征图。另外,这种运算对输入层的每个通道独立进行卷积运算,没有有效地利用不同通道在相同空间位置上的特征信息,因此需要Pointwise Convolution来将这些特征图进行组合以生成新的特征图。
Pointwise Convolution运算与常规卷积运算非常相似,它的卷积核尺寸为1×1×m,其中m为上一层的通道数。所以这里的卷积运算会将上一步的特征图在深度方向上进行加权组合,生成新的特征图,有几个filter就有几个输出特征图。如图2-18所示,其中W_ij是一个标量,表示kernel size是1×1。
卷积层在深度学习中还有更多的变形,在此不展开叙述。
图2-17 Depthwise Convolution
图2-18 Pointwise Convolution
2.激活运算
激活函数用于对卷积运算之后的特征值进行非线性运算,常见的激活函数有sigmoid和ReLU等。
在神经网络的雏形——感知器出来的时候,受真实人脑的神经元研究的影响,一个神经元接受和它的输入相连的神经元的信号,如果此信号超过阈值,则该神经元处于激活状态,继续向和它的输出相连的神经元传递信号,否则处于非激活状态。所以,感知器的激活函数是0-1的阶跃函数,或者输出1表示被激活状态,或者输出0表示非激活状态。但是,阶跃函数的数学性质不好,难以使用梯度下降算法进行机器学习。所以,阶跃函数被光滑化为sigmoid函数(见图2-19),同时产生了诸如tanh函数等变种。但是从图2-19可以看出,当x值较大(正数)或者较小(负数)时,其导数值趋于0,在梯度下降算法中体现为梯度消失问题,使得对参数的学习没有效果,因为梯度为0,那么参数也就无从改变了。所以,现在ReLU激活函数的应用开始广泛起来。
图2-19 sigmoid函数
ReLU公式是Output=Max(zero,Input)。如果特征值大于0则被激活,否则特征值归0。如图2-20所示,左边是卷积结果,右边是经过激活函数ReLU之后的特征值。
图2-20 ReLU运算
3.池化运算
在通过卷积运算取得特征图之后,池化运算将特征图按照局部区域进行聚合,从而得到更小的特征图。更小的特征图意味着后续更少的计算量。池化根据聚合方式不同分为最大值池化(max pooling)和平均值池化(average pooling)。如图2-21所示,特征图局部区域大小为2×2,即每个2×2区域聚合成一个输出值,因此4×4大小的输入特征图被池化之后得到2×2的特征值。最大值池化取4个值中最大的一个作为输出值,平均值池化取4个值的平均值作为输出值。
4.全连接运算
全连接运算将前一层输出的所有值进行加权和运算得到一个输出值(卷积运算是对前一层输出的局部区域的值进行加权和运算)。如图2-22所示,左边列是前一层的特征值,右边列是全连接运算结果。全连接一般用做神经网络的最后一层,每个输出值对应某个分类的置信值。
图2-21 池化运算
图2-22 全连接运算
数学上,我们还可以将全连接层看作一种特殊的且bias为0的卷积层。如果全连接层的上一层是卷积层,则不妨假设卷积层输出为a个e×f的特征图,而全连接层一共有b个神经元,那么就有b个filter,每个filter的kernel size为e×f,用式(2-8)可以表示为
(P1,P2,...,Pa)(filter_1,filter_2,…,filter_b)=(Q1,Q2,…,Qb)
且
filter_i=(W_i1,W_i2,…,W_ia)
其中,Pj的行列数分别为e和f;W_ij的行列数分别为e和f;Qi的行列数都为1,是一个标量。该运算过程可以用图2-23来直观表示。
图2-23 前一层为卷积层的全连接层的卷积角度示意图
如果此全连接层的上一层也是全连接层,假设上一全连接层有a个神经元,本全连接层有b个神经元。如果将上一层的输出看成a个1×1的特征图,那么这种情况就和上一种情况相同。如果将上一层的输出看成1个a×1的特征图,那么用式(2-8)可以表示为
(P)(filter_1,filter_2,…,filter_b)=(Q1,Q2,…,Qb)
且
filter_i=(W_i)
其中,P是a行1列的二维矩阵,即列向量;W_i是长度为a的列向量;Qi是一个标量。该运算过程可以用图2-24来直观表示。
实际上,不少的计算操作都可以从卷积角度来实现,因此当我们设计出新的模型算法后,并不一定需要马上定制一个新层,有时候可以借用已有的卷积层来实现,如注意力图卷积的实现。
图2-24 两个全连接层相接的卷积角度示意图
2.4.4 层的合并优化
深度学习由多个不同的层组成,这些层前后相连,进行一层接一层的运算。那么,很自然的想法就是把不同的层融合起来,根据算法的理论依据进行合并,这样既可以达到更好的执行效率,也不会有数学上的遗漏。层合并(layer fusion)就是一种将若干层合并成一层从而减少网络运算步骤的优化方法。在OpenCV源代码中,文件modules\dnn\src\dnn.cpp中第1938行开始的fuseLayers函数完成这项优化工作,主要有以下3种类型。
1.BatchNormalization层的合并优化
当出现连续的卷积层、批归一化(BatchNormalization)层、比例(scale)层和激活函数层的时候,可以将这4层合并到一个卷积层中,这种网络子结构随着批归一化层(Batch Normalization,BN)的提出而出现,常见于ResNet类网络。
批归一化层希望解决输入数据的分布变化,因为当机器学习通过样本数据进行有监督学习时,学到的参数除了和模型本身相关外,还与样本数据本身的分布有关。所以,假如样本数据的分布不停发生变化,则训练会非常困难,这种情况发生在神经网络中的每一个隐含层,因为每调整一次参数,隐含层的输入数据的分布都会因为前一层网络参数的变化而变化,这称为Internal Covariate Shift,也是批归一化层希望解决的问题。实际上,批归一化层还无法从理论上来完美解释是如何解决这个问题的,只是将输入数据归一化到正态分布N(0,1),数学公式如下所示:
其中,ε可以近似当作0看待
另外,考虑到这样的归一化可能是不恰当的,在某些情况下不利于参数的学习,因此,批归一化层又增加了数据的缩放平移功能,可以将数据还原恢复到归一化之前的输入,当然,通过训练学习得到的缩放平移参数,也可能会将数据缩放平移到另外一种更加合适的分布。在训练时,对每个batch的所有数据进行归一化,也就是说,公式中的均值E和方差Var是基于batch数据得到的,这也是批归一化层名称中batch的来源;而在推理时,则根据所有的训练数据进行归一化,也就是说,此时公式中的均值和方差是来自所有样本数据的无偏估计。在OpenCV DNN中,对应着Btach Normalization层和比例层,在推理阶段,这些层的参数都是常数。未经合并的各层数据的处理过程如图2-25的左半部分所示。
图2-25 基于Batch Normalization层的合并优化
输入数据是X,经过卷积层后的数据用R表示,这里的卷积层是指纯粹的卷积,不包括激活函数,OpenCV DNN模块加载模型文件后,就会得到这样的卷积层。所以,对于R中的每一个元素r,它是来自X中部分数据的加权和,具体是哪部分的数据,是由卷积层的参数(诸如kernel size等)决定的,这里简单地用f(X)表示,并且,在数学上,假设f(X)是一个行向量,卷积层参数W是一个列向量,卷积层参数b是一个标量,那么r可以表示为
r=f(X)·W+b
经过Batch Normalization层,输出数据为S,其每一个元素s可以表示为
s=(r-mean)/sd
其中,mean和sd是样本的均值和标准差。
在经过比例层,输出数据为T,其每个元素t可表示为
t=ks
最后经过激活函数层输出的数据为Y,其每个元素y可以表示为
y=activation(t)
综上所述,可以得到y和f(X)之间的关系为
y=activation(k·(f(X)·W+b-mean)/sd)
=activation(f(X)·(k/sd)·W+k·(b-mean)/sd)
所以,令
W'=(k/sd)·W 表示W中每个元素都乘以k/sd
b'=k·(b-mean)/sd 式中的每个符号都是标量
就可以得到一个新的卷积层,即
y=activation(f(X)·W'+b')
也就是图2-25右半部分所示。
所以,通过事先调整卷积层的W和b参数,可以实现将连续的卷积层、批归一化层、比例层和激活函数层合并到一个卷积层的效果。
2.Elewise层的合并优化
多个卷积层通过Elewise层相加的结构,可以合并优化为两个卷积层,如图2-26所示,这种网络子结构常见于ResNet类网络。
在图2-26上部分的原始计算过程中,X1和X2分别经过不包括激活函数的卷积层,得到Z1和Z2,然后Z1和Z2的相应元素相加,得到Z,再经过激活函数层,得到最终结果Y,这个计算过程一共涉及4个层。经过合并优化后,对X2卷积得到Z2保持不变,同时,原来的卷积层1进行了扩展,不再是标准的卷积层,而是扩展了一个输入,用来接受Z2,而且将最后的激活函数层也吸收合并进来,从而构成了卷积层1扩展,这样,整个计算过程只涉及两个层即可。
图2-26 多个卷积层相加的合并优化
3.Concat层的合并优化
对于多个层通过Concat层进行连接的情况(基于第一维度的连接),在用OpenCL作为后端的时候,可以将Concat层从网络中剔除,直接将前面的多个层(如图2-27中的层1、层2和层3)的输出写到后续层(如图2-27中的层4)的输入上。这种网络结构常见于MobileNet网络。
如图2-27所示,Concat层的输入是Z1、Z2和Z3,输出则是Z,这意味着这时候有4块内存空间,分别存储着Z1、Z2、Z3和Z,如图2-28所示。
Concat层要做的事情就是将Z1、Z2和Z3复制到Z对应的内存空间的合适区域。显然,这里有两个问题,一是内存空间消耗,二是复制动作影响性能。
能否只分配一片对应Z的内存空间,然后层1、层2和层3直接写到相应的内存位置呢?
如果以CPU为后端,那么改动比较大,需要这些层知道这些信息,才能写入相应的位置。如果以OpenCL为后端,借用sub buffer的概念,就可以使这些信息对所有层都是透明的,所有层的实现还是如常进行。
图2-27 Concat层示意图
在以OpenCL为后端的实现中,首选调用clCreateBuffer为Z生成所需要的buf,然后调用clCreateSubBuffer在buf的基础上,依次生成buf1、buf2和buf3,分别作为Z1、Z2和Z3的存储空间。而根据OpenCL的特性,buf1、buf2、buf3是与buf共享存储的,如图2-29所示。
图2-28 Concat层的复制操作
图2-29 基于clCreateSubBuffer的合并优化
因此,这种方法既节约了存储空间,也避免了复制操作,从而提高了性能。而对于层1、层2、层3和层4来说,其输入/输出数据的存储空间都是由cl_mem来封装的,而buf、buf1、buf2和buf3都是cl_mem类型,因此对这些层的实现来说并无区别,无须特殊处理、特别对待。