普通视图

发现新文章,点击刷新页面。
昨天以前SatanWoo

目标检测之 Selective Search

作者 SatanWoo
2020年4月7日 00:36


最近因为工作上的事,搞了一点非常基础目标检测相关的东西。正好在学习之余梳理了下之前自己认知错误的一些地方,记录一下。

起因


之前对于目标检测的了解停留于深度学习部分,比如 Fast-RCNN / Faster-RCNN / Yolo 等等,对于候选框域搜索算法主要还是对于 RPN 的认知。


但是这次在工作中了解到了 Selective Search 的概念,没想到在小样本训练的过程中精度也不错,性能还很好,哈哈。因此决定深入研究下。Selective Search 从大类上也可以属于 Region Proposal 的思想,但是主要的思想却是来源于传统的图像处理。


相关的论文发表于 IJCV 2013 《Selective Search for Object Detection》,大家可自行阅读获取更多细节。

主要还是学习目的,业界主流的还是采用 Faster-RCNN 的做法。

Selective Search


目标检测问题相对来说比图像分类复杂点,因为一般情况下要同时检测出多个子物体的位置(及可能需要的分类目的)。最原始的做法就是对于一张图像的每个可能位置都进行搜索,但是这里会产生一个两个互相增加复杂度的问题?

  • 我们要识别的物体在哪?我们要识别的物体大小是多少?长宽比要不要考虑?


简单来说,假设知道一个待识别的物体左上角顶点处于(x, y),那么长和宽分别设置多少呢?设置小了,可能没有办法得到正确要识别的物体;设置大了,可能又把要分开区分的两个或多个物体合在了一起。


因此,这种传统的做法产生的搜索空间基本可以认为是无穷尽的。


那么自然而然地,我们的优化的想法肯定是减少搜索空间的大小!怎么做呢?


答案说难也不难,就是只找哪些可能是物体的区域。从区域这个维度进行搜索,而不是全图像的像素级查询。

全图搜索绝大多数的搜索像素包含区域是不包含物体的,实质上是浪费,可以通过如下两张图进行直观对比。








基于此,作者首先利用图像分割的想法,来获取可能是物体的区域;当然,这种层次的分割肯定不准


进一步地,考虑掉物体之间诸如包含等关系,通过
合并的方式来构建层次化**的潜在物体区域。


所以整篇论文的核心就可以归纳为如下的数学公式:



  • 通过图像分割算法得到初始区域集合 R = {r1, ….. rn},这个很容易理解吧,就是图像分割。
  • 设定一个相似集合 S,初始为
  • 对于初始区域集合相邻中的每一对(ri, rj),计算相似度(下文会说如何计算相似度),得到 s(ri, rj),将其加入之前的相似集合 S 中。
  • 当 S 不为空的时候,从 S 中获取相似度最大的一对 s(ri, rj),将这两个 ri, rj 区域合并,称为 rt。
  • 把所有和 ri, rj 相关的相似度对都从 S 中移除掉。(ri, rj 已经不存在了,变身为 rt)
  • 把新得到的 rt,在分别和其邻区域的 rx 们,计算相似度对,存入 S 中。
  • 把 rt 加入到区域集合 R 中。
  • 重复步骤,知道合并到最后只有一个区域了(即 S 为空)。


这个时候,R 集合中的所有区域,就是通过 Selective Search 得到的候选框区域。


值得注意的是,这种计算方式得到的 R,本身就包含了多层次的关系。

如何合并


前面我们提到了,我们初始的待定区域是基于图像分割得到的一批候选集,但是这些候选集的质量还比较“糙”,粒度也不一定对,需要合并甚至多次合并来处理一下。因此,如何合并也是一个相对值得思考的问题。

截屏2020-04-07上午12.33.29.png

上两张图不难看出,初始化的图像分割对于目标检测来说是不能直接使用的。


其实这篇文章,作者也坦诚道:图片的样式千变万化,某些图片里面可行的方案到了另外一些图片中就不适用了。 因此,作者采用了多种方案混合的合并方法。

  • 比如,背景色大块区域和前景色不同的主体可以很明显区分。
  • 比如,材质 / 纹理等也可以比较明显区分出待检测的物体。
  • 比如,形状和大小也可以做为检测手段区分待检测物体。


有了这些可以参考的思路,作者设计了四合一的合并公式。

  • 颜色相似度
  • 纹理相似度,这里使用了 SIFT 算法。
  • 小区域合并优先级度。这里解释下,作者为了避免出现“大鱼吃小鱼”的现象,即一块区域不断膨胀,吞并周围区域,所以采用了尽量将小区域先分别合并,始终保持大小类似的方式。
  • 距离。如果区域ri包含在rj内,毫无疑问应该立刻合并,另一方面,如果ri很难与rj相接,不应该合并在一块。这里定义区域的合适度距离主要是为了衡量两个区域是否更加“吻合”,其指标是合并后的区域的Bounding Box(能够框住区域的最小矩形BBij)越小,其吻合度越高。

Selective Search 代码理解


读顶尖学术会议论文的好处就是一般对应的代码都会开源,即使论文读的云里雾里,但是只要能大致理解思路,配合源代码深入分析,总是能懂。


这篇论文对应的代码开源在Selective Search,代码总计也就 300+ 行(当然有些非核心代码直接依赖了库),很容易理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def selective_search(
im_orig, scale=1.0, sigma=0.8, min_size=50):
'''Selective Search

assert im_orig.shape[2] == 3, "3ch image is expected"

# load image and get smallest regions
# region label is stored in the 4th value of each pixel [r,g,b,(region)]


1】图像分割
img = _generate_segments(im_orig, scale, sigma, min_size)

if img is None:
return None, {}


2】获取对象总大小
imsize = img.shape[0] * img.shape[1]

3】获取初始集合
R = _extract_regions(img)

# extract neighbouring information

4】计算相邻的区域
neighbours = _extract_neighbours(R)

5】计算初始化的相邻区域相似度
S = {}
for (ai, ar), (bi, br) in neighbours:
S[(ai, bi)] = _calc_sim(ar, br, imsize)


6】就是之前我们说的搜索过程
# hierarchal search
while S != {}:

# get highest similarity
i, j = sorted(S.items(), key=lambda i: i[1])[-1][0]

# merge corresponding regions
t = max(R.keys()) + 1.0
R[t] = _merge_regions(R[i], R[j])

# mark similarities for regions to be removed
key_to_delete = []
for k, v in list(S.items()):
if (i in k) or (j in k):
key_to_delete.append(k)

# remove old similarities of related regions
for k in key_to_delete:
del S[k]

# calculate similarity set with the new region
for k in [a for a in key_to_delete if a != (i, j)]:
n = k[1] if k[0] in (i, j) else k[0]
S[(t, n)] = _calc_sim(R[t], R[n], imsize)

regions = []
for k, r in list(R.items()):
regions.append({
'rect': (
r['min_x'], r['min_y'],
r['max_x'] - r['min_x'], r['max_y'] - r['min_y']),
'size': r['size'],
'labels': r['labels']
})

return img, regions

  • 第一步,通过经典的图像分割算法获取分割的块。这一步留到后续研究 felzenszwalb 算法再说吧,暂时我也不会。
  • 其实第一步已经得到对应的区域了,但是在算法实现上只是做了一个个标记,所以还需要处理下,变成我们需要的 R 集合。这步里面已经做好了大量的计算处理,后续直接按照论文层级化调用就行。
  • 计算相邻的区域,对应产生初始的 S 集合。
  • 对相邻的区域计算最大相似度,然后合并。
  • 后面就重复我上问的内容了。


大致内容就这样,当然细节还有不少值得研究的,可以继续深入,后续再读读。


最后,作者这 Python 写的真是溜。

实现 MNN 模型的可视化工具

作者 SatanWoo
2020年2月6日 23:10

Netron 是一个支持 TensorflowPyTorchMXNetNCNNPaddlePaddle 等深度模型格式的可视化框架。去年国庆前的时候我稍微研究了下相关的代码,重点关注其将其是如何设计出一套兼容不同模型格式表征,用来归一化展现不同的深度学习框架模型。

研究完成后,我利用如下两个 Commit 作为 Pull Request 提交给了作者,用以支持 MNN 的模型可视化。

从中也不难看出我扎实的英语表述能力(我果然是个国际化人才)。

这篇文章会从架构设计、标准定义、巧用JS解析等几个方面来阐述

架构设计

整体上,按照我个人的理解,Netron 的架构可以简要展现如下:

最基础的应用部分及运行环境,是 Electron 这个跨平台框架直接呈现的。
当然,一些诸如基础zip/gzip用于解压等等的库我们也统一归类到支撑里。

然后是一套经典的 MVC 的结构,app.js 作为整体的 controller ,负责整个应用的功能逻辑,如导出图片、菜单管理、保存加载等等。这一层我们需要的做事非常少,只要将 MNN 支持的模型后缀 .mnn 注册进去即可。 然后是是对应的 view.js,这块实际上还是一层 controller,类比我们常说的子控制器,专门用于处理主视图的逻辑,如下图所示:

从这块开始,我们就要注意了,因为这里开始通过工厂方法对应的根据读取文件类型的不同,托管给了不同的自定义 xxx.js 来处理后续步骤。 比如.marmodelprototxt 等格式的模型会首先托管给 mxnet.js来处理。如果存在重名,则按照先后顺序依次尝试。

view.ModelFactoryService = class {
    constructor(host) {
        this._host = host;
        this._extensions = [];
        this.register('./onnx', [ '.onnx', '.pb', '.pbtxt', '.prototxt' ]);
        this.register('./mxnet', [ '.mar', '.model', '.json', '.params' ]);
        this.register('./keras', [ '.h5', '.hd5', '.hdf5', '.keras', '.json', '.model' ]);
        this.register('./coreml', [ '.mlmodel' ]);
        this.register('./caffe', [ '.caffemodel', '.pbtxt', '.prototxt', '.pt' ]);
        this.register('./caffe2', [ '.pb', '.pbtxt', '.prototxt' ]);
        this.register('./pytorch', [ '.pt', '.pth', '.pkl', '.h5', '.t7', '.model', '.dms', '.pth.tar', '.ckpt', '.bin' ]);
        this.register('./torch', [ '.t7' ]);
        this.register('./torchscript', [ '.pt', '.pth' ]);
        this.register('./mnn', ['.mnn', '.tflite']);
        this.register('./tflite', [ '.tflite', '.lite', '.tfl', '.bin' ]);
        this.register('./tf', [ '.pb', '.meta', '.pbtxt', '.prototxt', '.json' ]);
        this.register('./sklearn', [ '.pkl', '.joblib', '.model' ]);
        this.register('./cntk', [ '.model', '.cntk', '.cmf', '.dnn' ]);
        this.register('./openvino', [ '.xml' ]);
        this.register('./darknet', [ '.cfg' ]);
        this.register('./paddle', [ '.paddle', '__model__' ]);
        this.register('./ncnn', [ '.param', '.bin', '.cfg.ncnn', '.weights.ncnn']);
        this.register('./dl4j', [ '.zip' ]);
        this.register('./mlnet', [ '.zip']);

    }

在这上层是一层标准定义层,用于抹平不同模型之间的表达方式,用归一化的逻辑来进行处理,至于怎么把自己的模型表征映射成归一化的逻辑,就需要编写对应 xxx.js 来自行处理,后文会以 MNN 来进行举例。

最上层就是对应各个深度框架自行的逻辑处理了。其中包含了数据格式及对应解析(如 flatbuffer)、内容校验、构图等等,后文也会用 MNN 举例说明。

标准定义

这一环是一个很不起眼但是却非常重要的环节。 每种深度模型框架都有其自定义的模块结构和模块构成,一般都以 Flatbuffer Schema 的形式构成。(当然也有例外)以MNN 为例,其对应的模型结构大致如下图所示:

上图引用自FlatBuffers,MNN模型存储结构基础 —- 无法解读MNN模型文件的秘密

同理, TFLite 的模型也可见 TFLite.schema,不再赘述。

从定义中不难看出,TFLitemodelgraphSubGraph 等;而 MNN 对应的就是Net;再往下一层 TFLiteOperatorOptions;而 MNNOPOPParameter;至于 NCNN 则是 Layer

如果是从整个架构角度去兼容不同的框架,必然会有着大量的 messy code。因此作者定义了一套标准表征,让不同的深度模型自己去解析,然后附着自身的逻辑到这同一套表征上。

  • Model ,表示模型的静态表示。
  • Graph ,表示模型的计算图表示。
  • Node ,一个操作对应一个节点。
  • Tensor ,输入输出数据。
  • Parameter ,对应的属性。
  • Argument ,对应的属性值。

上述 ParameterArgument可以简单认为一一对应吧,都认为是属性值即可。

一图胜千言,下图比较好的展现了术语和对应的表征:

这样不同的框架模型只要在自己对应的 xxx.js 中,把图,OP对应的数据填充至对应的地方即可。

这里依然以 MNN 举例:

  • 我们不存在 subgraph 的概念,直接把 ModelGraph 等价于一个 net即可。
  • net 中取出 oplist ,对应创建成 Node
  • oplist 中每个 op ,取出对应的 tensorIndex,根据 nettensorNametensorIndex 来创建对应的 tensor
  • op 中根据 opparameter 的种类,从 op.main 中取出不同的数据来填入 paramter / argument这块是解析的大头,如果没想好方式,就会非常浪费时间,下文重点说。

数据格式

诸如 MNNTFlite 都选用了 Flatbuffer 来进行数据的保存,而官方的 flatc 程序支持直接根据定义的 schema 文件生成对应的 generated.js,命令如下:

./flatc -s ~/yourPathTo/MNN/schema/default/Type.fbs

这个我看了下很多的同学的在处理多 Schema 定义的时候是对应的一个个生成 generated.js,这样维护成本比较大,既然我们的已经使用了 include 机制,我们直接在生成过程中合并即可,如下所示:

./flatc --js -I ~/yourPathTo/MNN/schema/default/ ~/yourPathTo/MNN/schema/default/MNN.fbs --gen-all

这里有两个参数注意下:

  • -I,表示 include 从哪个路径进行搜索。
  • --gen-all,表示自动对生成的所有文件合并。

生成代码大致如下:

/**
 * @param {number} i
 * @param {flatbuffers.ByteBuffer} bb
 * @returns {MNN.Blob}
 */
MNN.Blob.prototype.__init = function(i, bb) {
  this.bb_pos = i;
  this.bb = bb;
  return this;
};
/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Blob=} obj
 * @returns {MNN.Blob}
 */
MNN.Blob.getRootAsBlob = function(bb, obj) {
  return (obj || new MNN.Blob).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Blob=} obj
 * @returns {MNN.Blob}
 */
MNN.Blob.getSizePrefixedRootAsBlob = function(bb, obj) {
  return (obj || new MNN.Blob).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};

具体关于 FlatBuffer 的细节,可以阅读我之前的文章,不再赘述。

避免冗余解析流程

上文提到 根据 OpParameter 来获取 main 中的数据,然后依次填入 parameter / argument 是比较耗费精力的步骤。我们所有的 OpParameter 类型有 74种(还在不断更新)

MNN.OpParameter = {
  NONE: 0,
  QuantizedAdd: 1,
  ArgMax: 2,
  AsString: 3,
  Axis: 4,
  BatchNorm: 5,
  BinaryOp: 6,
  Blob: 7,
  CastParam: 8,
  Convolution2D: 9,
  Crop: 10,
  CropAndResize: 11,
  Dequantize: 12,
  DetectionOutput: 13,
  Eltwise: 14,
  ExpandDims: 15,
  Fill: 16,
  Flatten: 17,
  Gather: 18,
  GatherV2: 19,
  InnerProduct: 20,
  Input: 21,
  Interp: 22,
  LRN: 23,
  LSTM: 24,
  MatMul: 25,
  NonMaxSuppressionV2: 26,
  Normalize: 27,
  PackParam: 28,
  Permute: 29,
  Plugin: 30,
  Pool: 31,
  PRelu: 32,
  PriorBox: 33,
  Proposal: 34,
  QuantizedAvgPool: 35,
  QuantizedBiasAdd: 36,
  QuantizedConcat: 37,
  QuantizedLogistic: 38,
  QuantizedMatMul: 39,
  QuantizedMaxPool: 40,
  QuantizedRelu: 41,
  QuantizedRelu6: 42,
  QuantizedReshape: 43,
  QuantizedSoftmax: 44,
  QuantizeMaxMin: 45,
  QuantizeV2: 46,
  Range: 47,
  Rank: 48,
  ReduceJoin: 49,
  ReductionParam: 50,
  Relu: 51,
  Relu6: 52,
  RequantizationRange: 53,
  Requantize: 54,
  Reshape: 55,
  Resize: 56,
  RoiPooling: 57,
  Scale: 58,
  Selu: 59,
  Size: 60,
  Slice: 61,
  SliceTf: 62,
  SpaceBatch: 63,
  SqueezeParam: 64,
  StridedSliceParam: 65,
  TensorConvertInfo: 66,
  TfQuantizedConv2D: 67,
  TopKV2: 68,
  Transpose: 69,
  UnaryOp: 70,
  MomentsParam: 71,
  RNNParam: 72,
  BatchMatMulParam: 73,
  QuantizedFloatParam: 74
};

Convolution2D 举例,它又有几个对应的参数:weightbiasquanParametersymmetricQuanpadXpadYkernelXkernelY 等等,需要解析。

一开始我采用了人肉的解析方式,代码就成了 if else 加上一大堆解析代码:

mnn_private.Convolution2DAttrBuilder = class {
constructor() {}

buildAttributes(metadata, parameter) {
    //var common = parameter.common();
    var attributes = [];
    var common = parameter.common();
    attributes.push(new mnn.Attribute(metadata, "padX", common.padX(), true));
    attributes.push(new mnn.Attribute(metadata, "padY", common.padY(), true));
    attributes.push(new mnn.Attribute(metadata, "kernelX", common.kernelX(), true));
    attributes.push(new mnn.Attribute(metadata, "kernelY", common.kernelY(), true));
    attributes.push(new mnn.Attribute(metadata, "strideX", common.strideX(), true));
    attributes.push(new mnn.Attribute(metadata, "strideY", common.strideY(), true));
    attributes.push(new mnn.Attribute(metadata, "dilateX", common.dilateX(), true));
    attributes.push(new mnn.Attribute(metadata, "dilateY", common.dilateY(), true));
    attributes.push(new mnn.Attribute(metadata, "padMode", mnn.schema.PadModeName[common.dilateY()], true));
    attributes.push(new mnn.Attribute(metadata, "group", common.group(), true));
    attributes.push(new mnn.Attribute(metadata, "outputCount", common.outputCount(), true));
    attributes.push(new mnn.Attribute(metadata, "inputCount", common.inputCount(), true));
    attributes.push(new mnn.Attribute(metadata, "relu", common.relu(), true));
    attributes.push(new mnn.Attribute(metadata, "relu6", common.relu6(), true));
    //var quanParameter = parameter.quanParameter();
    var weights = [];
    for (var w = 0; w < parameter.weightLength(); w++) {
        weights.push(parameter.weight(w));
    }
    attributes.push(new mnn.Attribute(metadata, "weights", weights, true));
    var bias = [];
    for (var b = 0; b < parameter.biasLength(); b++) {
        bias.push(parameter.bias(b));
    }
    attributes.push(new mnn.Attribute(metadata, "bias", bias, true));

    return attributes;
}

get hasMain() {
    return true;
}

这样的代码如果写完74个 OpParameter ,可维护性和后续的扩展也不够。

我们要巧用 JavaScriptReflect 能力以及属性等于与字符串值属性的特性

_buildAttributes(metadata, op, net, args) {
        var opParameter = op.mainType();    
        var opParameterName = mnn.schema.OpParameterName[opParameter];

        // 获取对应的类型
        var mainConstructor = mnn.schema[opParameterName];
        var opParameterObject = null;
        if (typeof mainConstructor === 'function') {
            var mainTemplate = Reflect.construct(mainConstructor, []);
            opParameterObject = op.main(mainTemplate);
        }
        this._recursivelyBuildAttributes(metadata, net, opParameterObject, this._attributes);
    }
    _recursivelyBuildAttributes(metadata, net, opParameterObject, attributeHolders) {
        if (!opParameterObject) return;
        var attributeName;
        var attributeNames = [];
        var attributeNamesMap = {};
        for (attributeName of Object.keys(Object.getPrototypeOf(opParameterObject))) {
            if (attributeName != '__init') {
                attributeNames.push(attributeName);
            }
            attributeNamesMap[attributeName] = true;
        }
        var attributeArrayNamesMap = {}; 
        for (attributeName of Object.keys(attributeNamesMap)) {
            if (attributeNamesMap[attributeName + 'Length']) {                    attributeArrayNamesMap[attributeName] = true;
                attributeNames = attributeNames.filter((item) => item != (attributeName + 'Array') && item != (attributeName + 'Length'));
            }
        }
        for (attributeName of attributeNames) {
            if (opParameterObject[attributeName] && typeof opParameterObject[attributeName] == 'function') {
                var value = null;
                if (attributeArrayNamesMap[attributeName]) {
                    var array = [];
                    var length = opParameterObject[attributeName + 'Length']();
                    //var a = opParameterObject[attributeName + 'Array']();
                    for (var l = 0; l < length; l++) {
                        array.push(opParameterObject[attributeName + 'Length'](l));
                    }
                    value = array;
                }
                else {
                    value = opParameterObject[attributeName]();
                    if (typeof value === 'object') {
                        this._recursivelyBuildAttributes(metadata, net, value, attributeHolders);   
                        value = null;
                    }
                }
                if (value) {
                    var attribute = new mnn.Attribute(metadata, attributeName, value);
                    attributeHolders.push(attribute);
                }                
            }
        }
    }

区区50多行代码就可以完成所有 OpParamater 及其对应的属性解析。

浅谈移动工程师跨界机器学习之路

作者 SatanWoo
2019年10月27日 01:23

题记

相信从事移动开发的朋友们肯定看到过一个表情包:“iOS 开发没人要啦”。

15706761042966.jpg

虽说是搞笑之图,却也反映了移动开发领域的部分焦虑感。网上甚至有文章贴出“难上加难”的数据,称:“相比于 2017 年,2018 年 Android 程序员人均面邀数减少40%,iOS 程序员降幅更高达57%,即平均每个移动端程序员在找工作时收到的面邀数比去年减少一半。”

撇开玩笑之言,移动开发人员的焦虑感来自何处?我从自身角度及与他人沟通,大致归纳出如下几点:

  • 跨平台框架、如 Flutter 对 Native 研发模式的冲击。
  • 业界关注重点从移动时代向人工智能等领域转移。
  • 对自身掌握技术壁垒的担忧。

细细品味这三点,我想开发者在面临业界趋势转移,担忧自身竞争力不足才是焦虑产生的内在根本。我曾和几个国内知名的 iOS 开发者闲聊,他们表示:都 9102 年了,从大量公开的文章来看,大家还是局限于研究 Runtime,Runloop,block 源码分析等一些比较缺少创新的知识点,让人感受行业的停滞不前。

当然,也有不少开发者在积极拥抱新技术。身边的许多朋友也在了解机器学习,自学相关课程等。但是其中大部分都反馈:学完了基础知识,不知道如何应用;也不知道这些东西能对自己日常工作带来怎样的帮助。最终的结果就演变成了学了就忘,无法产生实质价值

那是不是事情就此陷入了僵局呢?抱着怀疑及学习的态度,我在2018年中旬加入了手淘-端智能组,参与了一款名叫 MNN 的深度推理引擎的研发工作。这一年多的开发过程,让我对加深了对机器学习 / 深度学习的理解。但更重要的是,这一年多的亲身经历,让我对过去的观点产生了颠覆式的看法。

在这里,我并不想探讨如何学习机器学习,因为这样的文章数量已经浩瀚如海;相反地,我希望通过这篇文章,阐述在开发推理引擎 MNN 的过程中,我的思考与收获;希望给许多曾和我一样迷茫的移动开发者,一些亲历的感受和信心。

节约篇幅直接贴出 MNN 的 Github 地址:https://github.com/alibaba/MNN

定义清晰的跨界目标

相信有不少同学都曾和我一样,在了解机器学习的初期被诸多的公式推导所吓退,担心这是一个充斥着算法、数学、理论证明的技术领域。

这个观点没错,如果你想要设计出经典的 MobileNet、ResNet 这样的深度神经网络或者是对 Yolo 这样的结构进行复杂度优化,如 Yolo V3 等,你势必要对数学证明、算法优化等方面有较深刻的理解,从这个角度看,说一句很残酷的话:移动工程师跨界的机会不大。

但是机器学习是不是只有算法?这个观点是偏颇的,机器学习本质上是一个工程开发、算法优化与实际应用结合的领域。

用 深度学习领域的知名大牛 贾扬青 的观点来看:AI 是一个系统工程,90%的工作在算法之外

IMG_2784.JPG

换句话说,机器学习还包含系统工程这个范畴。往小了说,模型可视化工具、转换工具;往大了讲,学术界探索机器学习的编译优化系统,比如陈天奇提出的 TVM 等等,这都是机器学习的一部分。

上图是 MNN 官方在 Netron 上维护的可视化框架,我们应该是国内第一个主动支持可视化能力的深度学习推理引擎。

因此,对于我们移动开发者来说,我们更适合从系统工程的角度,通过实际编程解决问题,去探索机器学习

备注:这个观点并不是我自己想象出来。大家可以看看机器学习泰斗级人物 Jeff Dean 和李飞飞等人在2017年发表的机器学习系统白皮书。SysML: The New Frontier of Machine Learning Systems

系统工程角度的机器学习价值

如同大家学习编程时听过的那样,算法和数据结构是核心能力,一通百通。那么从系统工程的角度来看,无论是机器学习抑或是移动开发,存在诸多共通点是可以相互借鉴。限于篇幅,我仅仅列举几点能够切实帮助我自身日常开发的:

数据自描述协议

曾有人戏言“移动开发就是 UITableView + JSON”。虽然是句玩笑话,但也能看出数据传输在移动开发中的重要性。从个人经验来看,绝大多数的移动端数据传输协议基本都采用了 JSON(可能部分公司设计了自己的数据协议)。但是 JSON 存在几个缺点(不考虑优化的前提):

  • 不内存友好,相对会带来性能瓶颈。
  • 需要人为的解析流程。
  • 不具备很好的类型解释性。

为了解决类似的问题,一些新的数据协议,如 FlatBuffer 也渐渐进入大家的视线之中。尽管之前就对其有所耳闻,但是真的深入了解还是要追溯到开发推理引擎的过程中。在设计机器学习模型存储结构中,大名鼎鼎的 TFLite,MNN 等框架都采用了 FlatBuffer,这是一种具备 Access to serialized data without parsing/unpacking 的存储结构。它不仅减少了模型的存储大小、提升了性能,也对模型结构扩展、解析自描述起到了巨大的帮助。

尤其是协议自解析方面,真是令我大开眼界。简单来说,你只要按照 FlatBuffer Schema 要求的方式定义你的数据结构,剩下的编码 / 解析的过程都自动化完成。

这里以 MNN 框架中的 FlatBuffer 的使用举例,比如整个神经网络的拓扑架构定义如下:

1
2
3
4
5
6
7
8
9
10
11
table Net {
bizCode: string;
extraTensorDescribe: [TensorDescribe];
gpulibrary: GpuLibrary;
oplists: [Op];
outputName: [string];
preferForwardType: ForwardType = CPU;
sourceType: NetSource = CAFFE;
tensorName: [string];
tensorNumber: int = 0;
}

整体 MNN 中 Schema 的设计可以参考:https://github.com/alibaba/MNN/tree/master/schema/default

然后我们通过一行简单的命令(这里仅作演示举例)就可以自动生成 JavaScript 的对应代码。

1
./flatc -s -I ~/MNN/schema/default ~/MNN/schema/default/MNN.fbs
1
/**
 * @constructor
 */
MNN.Net = function() {
  /**
   * @type {flatbuffers.ByteBuffer}
   */
  this.bb = null;

  /**
   * @type {number}
   */
  this.bb_pos = 0;
};

/**
 * @param {number} i
 * @param {flatbuffers.ByteBuffer} bb
 * @returns {MNN.Net}
 */
MNN.Net.prototype.__init = function(i, bb) {
  this.bb_pos = i;
  this.bb = bb;
  return this;
};

/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Net=} obj
 * @returns {MNN.Net}
 */
MNN.Net.getRootAsNet = function(bb, obj) {
  return (obj || new MNN.Net).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};

/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Net=} obj
 * @returns {MNN.Net}
 */
MNN.Net.getSizePrefixedRootAsNet = function(bb, obj) {
  return (obj || new MNN.Net).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};

/**
 * @param {number} index
 * @param {MNN.TensorDescribe=} obj
 * @returns {MNN.TensorDescribe}
 */
MNN.Net.prototype.extraTensorDescribe = function(index, obj) {
  var offset = this.bb.__offset(this.bb_pos, 6);
  return offset ? (obj || new MNN.TensorDescribe).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null;
};

/**
 * @returns {number}
 */
MNN.Net.prototype.extraTensorDescribeLength = function() {
  var offset = this.bb.__offset(this.bb_pos, 6);
  return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};

/**
 * @param {number} index
 * @param {MNN.Op=} obj
 * @returns {MNN.Op}
 */
MNN.Net.prototype.oplists = function(index, obj) {
  var offset = this.bb.__offset(this.bb_pos, 10);
  return offset ? (obj || new MNN.Op).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null;
};

而用户在代码中使用这个拓扑结构,只要简单调用入口函数 getRootAsNet ,剩下来的一切都自动化完成。而当你要修改结构定义的时候,仅仅需要修改对应的 Schema 文件,重新生成对应的解析文件,无需人工逐字段手工修改。

限于篇幅有限,这里不过多展开对 FlatBuffer 的介绍,感兴趣的读者可以阅读 MNN 用户自发写的博客《FlatBuffers,MNN模型存储结构基础 —- 无法解读MNN模型文件的秘密》。

那这样的协议能不能应用于移动开发中并起到正向的作用呢?答案是肯定的,有兴趣的朋友可以阅读 Facebook 的相关文章。

汇编知识的深度掌握

部分读者可能知道,我和几位同事在知乎上开了一个专栏《iOS调试进阶》,重点分享 ARM 相关的汇编知识。会有这个想法是因为日常工作中排查许多 Crash 的时候,从源码层面已经无法定位,必须要依赖计算机执行的本质 - 机器码进行分析,而这正是汇编可以产生价值的地方。

但是汇编不仅仅局限于排查 Crash。在开发 MNN 过程中,涉及了大量的密集型计算操作。团队的一些大牛在指令实现层面根据流水线编排、硬件大小核数、缓存大小等等,使用手写汇编来精细化调度数据的读写与执行,使得MNN 的推理性能达到了业界一流的水准(无论是我们自己的 benchmark 抑或是利益无关的友商的评测都证明了这一点)。而阅读这些精心酿造的汇编代码,会让你感到,原来开发还能这么玩!

这里展示一个经典的 Bilinear 插值通过汇编的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
text
.align 5

asm_function MNNBilinearProcC1
//void MNNBilinearProcC1(const unsigned char *sample, unsigned char* dst, const int16_t* xFactor, const int16_t* yFactor, size_t w);

//Auto: x0:sample, x1:dst, x2:xFactor, x3:yFactor, x4:w

ld1 {v31.s}[0], [x3]
//Now x3 is no used
dup v30.4h, v31.h[0]
dup v31.4h, v31.h[1]

L8:
cmp x4, #8
blt End

LoopL8:
ld4 {v4.8b, v5.8b, v6.8b, v7.8b}, [x0], #32
ld2 {v0.8h, v1.8h}, [x2], #32//q0, q1
//(x00,x01) -> (y0)
uxtl v2.8h, v4.8b
uxtl v3.8h, v5.8b
umull v16.4s, v2.4h, v0.4h
umull2 v17.4s, v2.8h, v0.8h
umlal v16.4s, v3.4h, v1.4h
umlal2 v17.4s, v3.8h, v1.8h

uqshrn v18.4h, v16.4s, #4
uqshrn v19.4h, v17.4s, #4

//(x10,x11) -> (y1)
uxtl v2.8h, v6.8b
uxtl v3.8h, v7.8b
umull v16.4s, v2.4h, v0.4h
umull2 v17.4s, v2.8h, v0.8h
umlal v16.4s, v3.4h, v1.4h
umlal2 v17.4s, v3.8h, v1.8h

uqshrn v20.4h, v16.4s, #4
uqshrn v21.4h, v17.4s, #4

//(y0,y1) -> dst
umull v16.4s, v18.4h, v30.4h
umull v17.4s, v19.4h, v30.4h
umlal v16.4s, v20.4h, v31.4h
umlal v17.4s, v21.4h, v31.4h

uqshrn v2.4h, v16.4s, #16
uqshrn2 v2.8h, v17.4s, #16

uqrshrn v0.8b, v2.8h, #2

st1 {v0.8b}, [x1], #8


sub x4, x4, #8
cmp x4, #8
bge LoopL8

End:

相信我,当你从不懂汇编 -> 读懂汇编 -> 手写汇编,每前进一步,你会发现更广阔的天地。有一天当你要做性能优化,发现许多网上常见的手段都使用过了但仍然不起作用的时候,也许汇编就是你杀手锏。

GPU 相关知识融合

近些年来随着短视频的崛起,市面上渲染、多媒体相关的岗位也越加变得火热。而这些岗位无一例外都需要对 GPU 有着深度的了解。而操作 GPU,自然而然就少不了与 Shader 打交道。

Shader 其实就是专门用来渲染图形的一种技术。通过 Shader ,我们可以自定义显卡渲染画面的算法,使画面达到我们想要的效果。

但 Shader 的作用不仅仅作用于渲染。在机器学习领域,苹果的 Metal 框架所包含的Metal Performance Shader(MPS)也能用来做 GPU 计算,提升机器学习在移动端的执行性能。就连诞生已久的 OpenGL,也在最新的 OpenGL 3 标准中增加了计算纹理,支持 GPU 计算的能力。由此可见,尽管最初的目的并不相同,但是技术本质是相通的,最后都会产生微妙的化学反应。

上述几点,仅仅是个人抛砖引玉,展示机器学习和日常移动开发相互交织的冰山一角。从工程实现的角度,仍有许多值得探索并实践应用的,欢迎大家一起探讨交流。


与大家携手一起进步

读到这,可能有些读者内心的兴奋之情被熊熊点燃,恨不得立刻能将相关的知识学习起来;但也有部分朋友会觉得,可能只有 BAT 这样的大厂才会有实际的场景需要进行如此深入的研究和开发工作,有沮丧之情。

我对这种体会特别感同身受,因为去年刚转型开发 MNN 之初,我也有过手足无促,连简单的 Metal Performance Shader 都写不好。加上之前有些朋友通过 QCon 和云栖大会听闻了 MNN,也和我或其他同事进行过一些实现上或者应用方面的探讨。

因此,借着这个机会,除了希望通过这篇文章带领大家对【机器学习系统】有一个全新的认知之外,后续也会以连载的方式在以下两个方面给大家继续带来更多有价值的点:

  • 技术介绍,我会把 MNN 里面使用的相关技术点,逐个拆解,带领大家通过理论探索和实际编程相结合的方式来深入了解细节,反哺于大家日常的开发工作。

  • 最佳实践,目前在客户端领域应用机器学习的典型案例还比较缺乏。而我正好在过去一年多的时间里,探索了诸多的实践案例(比如大家耳熟能详的拍立淘、淘宝直播、AR试妆等等中都有 MNN 的身影哦~),我也会将其整理分享出来,和大家一起探索端智能的前行之路

对了,写了这么多文字,还请读者们见谅,允许我打个招人合作的广告吧

MNN 是阿里巴巴开源的一款轻量级、高性能深度学习推理引擎,用于解决深度神经网络模型在端侧推理运行问题。

从今年4月份开源到现在,我们始终在完善和响应社区用户的诉求,并保持着每两个月一次重大 Feature Release 的发布频率。

但我们团队的力量是有限的,而端上智能应用前进的道路仍然充满着广阔未被探索的区域,我们希望和大家一起教学相长,携手进步。

如果大家对移动端机器学习有什么好的想法和建议,也可以前往 Github 上,给我们反馈。

也欢迎对 MNN 感兴趣的朋友,扫描二维码,加入钉钉群,和我们一起交流或者直接加入我们团队哦(悄悄地说,人气有点高,一群都满啦)

用钉钉群比较方便我们在工作闲暇时间及时响应大家的问题和诉求,请大家多包涵啦~

结语

本文记录了过去一年多,个人参与 MNN 框架相关开发过程中的一些收获与心得。如何不分裂的看待机器学习与移动开发的关系,如何从看似不相关的领域寻找共同点,提升自己所处领域的价值和核心能力,是值得我们每位开发同学需要思考的。

在最后,还是要说一句:移动客户端的从业人员并不需要过多的焦虑和担忧,动态化、高性能、内核、渲染等等方向都充满前景。但是,你需要找到你所擅长且愿意为之深入的,这才是你保证在浪潮中不被拍翻的核心竞争力。

C++实现一个识别MNIST数字的卷积神经网络

作者 SatanWoo
2018年5月9日 01:04

新的一个财年加入了新的组,从事机器学习相关的工作。由于之前做的一直是iOS(略微底层)方面的事情,初来乍到,对很多东西不熟悉,在超级大神ZB的建议下,用C++实现一个多层前馈神经网络,来识别MNIST数据中的各种手写图片。

素材寻找

  • 感谢这位不知名的大佬提供的MNIST数据集,可以直接下载纯图片数据集
  • 搜索下载已经调整好的weightbias模型。(下文会介绍)

实现过程

关于实现多层前馈神经网络,网络上的答案数不胜数,但是大多数都是参考Tensorflow或者Caffe(PyTorch)去实现,我觉得这样很不好。因为学习一门新技术,虽然快速完成项目看出效果很重要,但是对个人来说,弄懂黑盒背后的故事非常重要。因此我决定不依赖任何的库来完全裸写。

当然,对于我来说,实现的完整度和正确性是第一位的,我并没有过多的关注性能。

虽然在深度学习高度发展的今天,类似于AlexNet这样的网络模型能够近乎完美的识别手写数据集。但是作为这个领域的入门级选手,我还是想追溯起源,从头开始做起。因此,在一番学习和搜索后,我选定了LeNet-5模型进行编写。选择它的原因主要有如下几点:

  • 它自身是一个多层的前馈网络模型。
  • 麻雀虽小,五脏俱全。包含了卷积层、全联接层、放缩、灰度以及池化层。同时还引入了ReLuSoftmax等激活函数。
  • 实现简单,哈哈哈哈哈

LeNet-5

LeNet-5整体是个非常简单的过程,包含如下步骤:

  1. 接受一个RGBA的图像。这个很简单,直接在RGBA的颜色空间下读取即可。
  2. 放缩到28 * 28(保留所有的feature)的大小,采用的是bilinear插值方法。
  3. 灰度化,公式如下:r / 255.0 * 0.299 + g / 255.0 * 0.587 + b /255.0 * 0.114
  4. 取反,即255.0 - 灰度化的结果
  5. 5 * 5大小的卷积,加Relu,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)
  6. 最大池化层降采样。
  7. 5 * 5大小的进行第二次卷积,加ReLu,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)
  8. 最大池化层降采样。
  9. 全链接计算 + ReLu
  10. 全链接输出10个featureMap
  11. Softmax计算并去除最大的值,即为检测的数字结果。

整体实现上没什么需要特别注意的,如果对这里的名次不懂,可以上网自行查询对应的解释。不过这里有一点很不好,浪费了大量的时间在调试我定义的张量的格式和网上找到的weight模型的格式。

什么意思呢?我大致用如下的图解释下我自身设计的张量是如何存储的。

理论上来讲,张量有三个维度,width, height, featureChannels。我在设计我的张量存储上按照的data[height][row][featureChannels]的方式,然后全部拍成了一维。如图所示:

之所以想这么做,主要是瞄了眼TensorFlow也是类似的设计,然后印象中CUDA也是按照这样维度进行存储,貌似可以有效做并行计算拆分。(这个不确定)

然后为什么卡了很久呢?主要是weightsbias的模型文件是个按照自定义协议二进制流的文件(非结构化的数据)。

这模型由于是我网上找的,一开始没注意模型的自定义协议和我设计的张量直接的区别。我直接按照我的张亮顺序进行了相乘,得到了十分错误的结果。

当然,bias模型没什么好说的,就是按照outputFeatureMap定义的纯一维数组,不会出错。

后来发现这个模型是基于苹果的MPS设计的模型,它的模型是这样的数据结构weights[outputChannels][height][width][inputChannel/group]。第一维在计算的时候需要和我做个映射,所以这里没搞清楚模型格式,查了比较久。

当然,我在加载权重和bias这块还是做了点小油画。用了mmap,避免一次性直接搞进来太大的数据,反正看起来weightbias这块并不需要一次性的读取,而且只读的mmap还能合理利用iOS设备上的clean memory回收机制。

框架设计

  • 网络模型拓扑结构,MinstGraph。这里偷懒了,因为LeNet-5也没啥复杂的拓扑结构,不用考虑多个节点连接,直接线性跑下去就好。

  • 支持任意多维度的张量,类似Tensorflow里面的Tensor,这里对应了MinstImage

  • 各种Layer,如MaxPoolingLayerConvolutionLayer, FullConnectionLayer等等。
  • 各种激活函数,如ReluSoftmax等等。
  • 一些辅助函数之类的。

代码下周发吧。

效果

准确度一开始不怎么高,检查了很多遍代码,确实发现了一些问题,比如数据精度问题。

一开始从图像的角度理解,认为用unsigned char存储一个数据点就够了,毕竟图像像素点(RGB空间下)的取值范围就是0-255

后来发现在计算卷积、全链接层的时候会产生很多小数,用unsigned char存储精度全部丢失了。因此修改成了现在的float设计。对效果提升还是比较明显的。

后来专门跑了下苹果基于Metal实现的卷积神经网络,由于上述我自身实现的所有Layer和激活函数在苹果的框架中都有内置,因此把网络模型搭起来跑就完了(除非苹果自己实现有错)。然后对比我的每一层输入输出和对应的MPSImage输入输出。

不过这里有一点要注意,MPSImage的数据格式是NHWC,这里的N是把C按照4对齐后分成的不同batch。如下图所示:

假设是一个2 * 1 * 5(w h c)的数据,会先把前4层排完,再进行第五层的排列,按4对齐后多出来的三个层补0。

我的代码里面MinstImage提供了一个print方法就是专门做输出对比的。嗯,对比了我的实现和用苹果框架的下输入输出,结果是一致的。(除了iOS10上不支持bilinear插值)

最终效果如下图:

备注:

如果直接用我开头提到的MNIST数据集,由于每张图都是28 * 28的灰度图,因此不需要resize + grayscale,直接从取反开始计算就可以了。

后续规划

  1. 做神经网络还是挺有意思,不过目前还是参考简单的模型结构实现,主要做inference。还没真正去研究训练模型这块。这块需要多深入研究算法,多读论文。

  2. 目前并没有真正设计Session的概念。理论上一张图就是一个静态的拓扑结构的组合而已,不应该承担类似执行run的功能。后续业余时间还会继续实现完整这套逻辑,将静态结构和动态执行结构彻底分离。

  3. 后续有时间的话,可以尝试实现别的模型。同时支持从文件中读取已经建立好的模型,类似Caffee模型之类的

  4. 移植到GPU上。

最后

文章的最后,按照惯例还是向我的偶像致敬。机器学习发展到今天,已经成为了不可忽视的研究方向。对于我们这样的后生来说,站在大牛的肩膀上是我们的福气和基石。而像杨萧玉这样,能够一周时间内学完机器学习课程,发表博客造福大众,才是推动机器学习不断发展的中坚力量。相信在他的带领下,我们国家一定能够在2030年达到全球领先的AI技术水准。

❌
❌