2018年5月31日星期四

超全总结:神经网络加速之量化模型 | 附带代码

量化模型(Quantized Model)是一种模型加速(Model Acceleration)方法的总称,包括二值化网络(Binary Network)、三值化网络(Ternary Network),深度压缩(Deep Compression)等。鉴于网上关于量化模型的不多,而且比较零散,本文将结合 TensorLayer 来讲解各类量化模型,并讨论一下我们过去遇到的各种坑。文章最后会介绍一些关于人工智能芯片的技术。 

TensorLayer 是一个基于 TensorFlow 的高级开发工具,提供大量数据处理和建模 API,具备灵活性高、运行速度快等优点。今年 3 月,TensorLayer 提供了一套搭建量化网络的试验版本 API,不过目前这套 API 依然用矩阵乘法而不是加减或 bitcount 运算来加速(我们等会会提到)。

因此,这套 API 并不能加速,关于产品部署,目前可以用 TensorLayer 训练模型,然后用自定义的 C/C++ 实现的二值化计算(TensorLayer 有可能会提供一套额外的专门运行二值化网络的框架,并支持可以从 TensorLayer 那读取模型)。

注意,作为试验版本,这套 API 有可能会被修改。更多关于模型加速的技术,可关注:https://ift.tt/2FZTnYs 

Keywords:模型压缩(Model Compression),模型加速(Model Acceleration),二值化网络(Binary Network),量化模型(Quantized Model)

随着神经网络深度增加,网络节点变得越来越多,规模随之变得非常大,这是对移动硬件设备非常不友好的,所以想要在有限资源的硬件设备上布置性能良好的网络,就需要对网络模型进行压缩和加速,其中量化模型由于在硬件上移植会非常方便,在理论上来讲,是非常有发展潜力的。

比较有名气的量化模型有 Deepcompression,Binary-Net,Tenary-Net,Dorefa-Net,下面对这几种量化模型进行介绍。

DeepCompression

SongHan 这篇文章可以说是神经网络压缩领域开山之作,怎么说呢这篇文章很早就注意到了,也复现了,做了很多实验。也一直想用到硬件参数压缩以及模型加速当中,在这个过程中遇到了很多问题,现在提出来跟大家一起探讨。

算法整体框架如图:

DeepCompression 主要分为三个主要的部分:剪枝,量化,哈夫曼编码,下面分别探讨这几种方法并且分析他们在硬件前向配置的加速潜力。

剪枝(purning):其实这个思路的核心非常简单,就是当网络收敛到一定程度的时候,作者认为阈值小于一定权重权重对网络作用很小,那么这些权重就被无情的抛弃了。注意,是抛弃,彻底抛弃,在复现的时候这个地方是一个大坑,被剪掉的权重不会再接收任何梯度。

然后下面的套路简单了,就是很简单的将网络 reload,然后重新训练至收敛。重复这个过程,直到网络参数变成一个高度稀疏的矩阵。这个过程最难受的就是调参了,由于小的参数会不断被剪枝,为了持续增大压缩率,阈值必须不断增大,那么剩下的就看你的调参大法 6 不 6 了。

当初为了解决这个问题还专门设计了一个基于准确率损失和压缩率上升的公式,用于压缩。算是效果还可以,自己调参真的很难受。

最后参数会变成一个稀疏的矩阵,作者自己提出了一种编码方式:

当压缩率低于一定的值时,编码解码开销其实是非常大的,甚至到一定范围,编码后的存储量甚至大于不压缩。

第二个就是量化了,将接近的值变成一个数。大概的思路如下:

需要注意的是,量化其实是一种权值共享的策略。量化后的权值张量是一个高度稀疏的有很多共享权值的矩阵,对非零参数,我们还可以进行定点压缩,以获得更高的压缩率。 

论文的最后一步是使用哈夫曼编码进行权值的压缩,其实如果将权值使用哈夫曼编码进行编码,解码的代价其实是非常大的,尤其是时间代价。还需要注意的是,DeepCompression 中对于输出没有压缩。所以这种方案对于硬件加速的主要作用体现在遇到 0 即可 zero skip,即使用判断语句替代乘法器。

Binary-Net

  • Quantized Neural Networks: Training Neural Networks with Low Precision Weights and Activations

  • Binarized Neural Networks: Training Deep Neural Networks with Weights and Activations Constrained to +1 or -1

  • XNOR-Net: ImageNet Classification Using Binary Convolutional Neural Networks

通常我们在构建神经网络模型中使用的精度都是 32 位单精度浮点数,在网络模型规模较大的时候,需要的内存资源就会非常巨大,而浮点数是由一位符号位,八位指数位和尾数位三个部分构成的。完成浮点加减运算的操作过程大体分为四步: 

1. 0 操作数的检查,即若至少有一个参与运算的数为零直接可得到结果;

2. 比较阶码大小并完成对阶; 

3. 尾数进行加或减运算; 

4. 结果规格化并进行舍入处理。 

带来的问题是网络在运行过程中不仅需要大量的内存还需要大量的计算资源,那么 quantization 的优越性就体现出来了,在 2016 年发表在 NIPS 的文章 Binarized Neural Networks: Training Deep Neural Networks with Weights and Activations Constrained to +1 or -1 中,提出了利用降低权重和输出的精度的方法来加速模型,因为这样会大幅的降低网络的内存大小和访问次数,并用 bit-wise operator 代替 arithmetic operator。

下面具体介绍一下这种方法的原理,在训练 BNN 时,将权重和输出置为 1 或 -1,下面是两种二值化的方法: 

第一种直接将大于等于零的参数置为 1,小于 0 的置为 -1;

第二种将绝对值大于 1 的参数置为 1,将绝对值小于 1 的参数根据距离 ±1 的远近按概率随机置为 ±1。

公式中是一个 clip 函数:

第二种二值化方式看起来更为合理,但是由于引入了按概率分布的随机一比特数,所以硬件实现会消耗很多时间,我们通常使用第一种量化方法来对权重和输出进行量化。

虽然 BNN 的参数和各层的输出是二值化的,但梯度不得不用较高精度的实数而不是二值进行存储。因为梯度很小,所以使用无法使用低精度来正确表达梯度,同时梯度是有高斯白噪声的,累加梯度才能抵消噪声。 

另一方面,二值化相当于给权重和输出值添加了噪声,而这样的噪声具有正则化作用,可以防止模型过拟合。所以,二值化也可以被看做是 Dropout 的一种变形,Dropout 是将输出按概率置 0,从而造成一定的稀疏性,而二值化将权重也进行了稀疏,所以更加能够防止过拟合。 

由于 sign 函数的导数在非零处都是 0,所以,在梯度回传时使用 tanh 来代替 sign 进行求导。假设 loss function 是 C,input 是 r,对 r 做二值化有:

C 对 q 的的导数使用 gq 表示,那么 q 对 r 的导数就变成了:

这样就可以进行梯度回传,给出一种包含 bn 的二值化网络的梯度算法:

BN 最大的作用就是加速学习,减少权重尺度影响,带来一定量的正则化,可以提高网络性能,但是,BN 涉及很多矩阵运算(matrix multiplication),会降低运算速度,因此,提出了一种 shift-based Batch Normalization。 

使用 SBN 来替换传统的 BN,SBN 最大的优势就是几乎不需要进行矩阵运算,而且还不会对性能带来损失。基于 SBN,又提出 Shift based AdaMax:

网络除了输入以外,全部都是二值化的,所以需要对第一层进行处理:

作者还对二值化网络扩展到 n-bit quantized:

二值化的论文对 mnist、cifar-10、SVHN 进行了测试,最后得到的 test error 如下:

完了作者为了挑战高难度,又用了 alexnet 和 googlenet 在 imagenet 上做了测试,看出来结果也是一般,所以较复杂的网络较大的数据集采用 bnn 看来影响还是蛮大的。

作者不服气又提出了一些小技巧,比如什么放宽 tanh 的边界啊,用 2-bit 的 activitions,也提升了一些准确率,作者也在 rnn 做 language task 上进行了二值化,结果也贴出来,分析了那么多模型,应该可以说在牺牲那么多运算和储存资源的情况下准确率差强人意。

x = tf.placeholder(tf.float32, shape=[batch_size, 28, 28, 1]) net = tl.layers.InputLayer(x, name='input') net = tl.layers.BinaryConv2d(net, 32, (5, 5), (1, 1), padding='SAME', b_init=None, name='bcnn1') net = tl.layers.MaxPool2d(net, (2, 2), (2, 2), padding='SAME', name='pool1') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn1')  net = tl.layers.SignLayer(net) net = tl.layers.BinaryConv2d(net, 64, (5, 5), (1, 1), padding='SAME', b_init=None, name='bcnn2') net = tl.layers.MaxPool2d(net, (2, 2), (2, 2), padding='SAME', name='pool2') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn2')  net = tl.layers.FlattenLayer(net)  net = tl.layers.SignLayer(net) net = tl.layers.BinaryDenseLayer(net, 256, b_init=None, name='dense') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn3')  net = tl.layers.SignLayer(net) net = tl.layers.BinaryDenseLayer(net, 10, b_init=None, name='bout') net = tl.layers.BatchNormLayer(net, is_train=is_train, name='bno') 

上面是给 MNIST 设计的一个 BinaryNet。 

作者最后又分析了一下时间复杂度和功率效率,毕竟 bnn 的主要任务就是压缩和加速,说了时间复杂度可以降低 60%,原理是说可以卷积核复用。

举个例子,因为一个 3 x 3 的卷积核做了二值以后,只有 2 的 9 次方个独一的卷积核,相比于没有二值化的卷积核,在文章中的 cifar-10 网络中独一的卷积核数量只有 42% 那么多。

内存资源减少了 31/32(原本每个参数 32bit,压缩后每个参数 1bit),运算资源,硬件层面上看 32bits 损耗 200 个位,1bit 只损耗一个位(bit-wise operation)。

最后在 gpu 上还可以进行 SWAR(single instruction,multiple data within register)的处理,对 xnor 进行优化,SWAR 的基本思想是将 32 个二进制变量组连接成 32 位寄存器,从而在按位操作(例如 XNOR)上获得 32 倍的加速。 

使用 SWAR,可以仅用 3 条指令评估 32 个连接:

就可以用 1(加和)+4(popcount,四个 8 位)+1(xnor)个 time cycle 来进行运算,原来的,则是 32 个 time cycle,提高了 32/6 倍的速度。

Xnor-Net 在 BNN 的基础上引入了比例因子,让二值化之后的参数和原始的参数的 L2 范数最小,提高了模型的精度。

对卷积操作的比例因子进行简化,降低了其运算复杂度。

由于在一般网络下,一层卷积的 kernel 规格是固定的,kernel 和 input 在进行卷积的时候,input 会有重叠的地方,所以在进行量化因子的运算时,先对 input 全部在 channel 维求平均,得到的矩阵 A,再和一个 w x h 的卷积核 k 进行卷积得到比例因子矩阵 K,其中:

在 imagenet 上结果也比 bnn 要好很多。

Ternary-Net

  • Ternary Weight Networks paper 

权值三值化的核心: 

首先,认为多权值相对比于二值化具有更好的网络泛化能力。其次,认为权值的分布接近于一个正态分布和一个均匀分布的组合。最后,使用一个 scale 参数去最小化三值化前的权值和三值化之后的权值的 L2 距离。 

基本原理阐述如下: 

参数三值化的方式如下:

其实就是简单的选取一个阈值(Δ),大于这个阈值的权值变成 1,小于-阈值的权值变成 -1,其他变成 0。当然这个阈值其实是根据权值的分布的先验知识算出来的。本文最核心的部分其实就是阈值和 scale 参数 alpha 的推导过程

参数三值化之后,作者使用了一个 scale 参数去让三值化之后的参数更接近于三值化之前的参数。具体的描述如下:

利用此公式推导出 alpha 的值如下:

由此推得阈值的计算公式如下:

由于这个式子需要迭代才能得到解,会造成训练速度过慢的问题,所以如果可以提前预测权值的分布,就可以通过权值分布大大减少阈值计算的计算量。文中推导了正态分布和平均分布两种情况,并按照权值分布是正态分布和平均分布组合的先验知识提出了计算阈值的经验公式。

三值化论文的最终结果如下:

反正就是抓住 BNN 一顿 diss 呗,谁让人家准确率高呢。

当然,这种方法有进化版本,我们完全可以将权值组合变成(-2,-1,0,1,2)的组合,以期获得更高的准确率。正好我之前也推过相关的公式,现在贴出来供大家参考,这个时候权值的离散化公式变成了:

Scale 参数的计算公式变成了:

此时阈值的计算公式变成了:

需要声明的是,这个算法我只在一个非常不知名的 matlab 的一个纯 cpu 版本慢到爆炸反正就是难以忍受那种框架上面实际实现过,取得了比三值化更高的准确率,但是!对于这个算法在 tensorflow 上面的实现我真是一筹莫展,因为 tensorflow 某些机制……算法的具体实现方式如下:

net = tl.layers.InputLayer(x, name='input') net = tl.layers.TernaryConv2d(net, 32, (5, 5), (1, 1), padding='SAME', b_init=None, name='bcnn1') net = tl.layers.MaxPool2d(net, (2, 2), (2, 2), padding='SAME', name='pool1') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn1') net = tl.layers.TernaryConv2d(net, 64, (5, 5), (1, 1), padding='SAME', b_init=None, name='bcnn2') net = tl.layers.MaxPool2d(net, (2, 2), (2, 2), padding='SAME', name='pool2') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn2')  net = tl.layers.FlattenLayer(net) net = tl.layers.TernaryDenseLayer(net, 256, b_init=None, name='dense') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn3') net = tl.layers.TernaryDenseLayer(net, 10, b_init=None, name='bout') net = tl.layers.BatchNormLayer(net, is_train=is_train, name='bno') return net 

上面是 TensorLayer 提供的三值化的 MNIST 测试代码。

权值三值化并没有完全消除乘法器,在实际前向运算的时候,它需要给每一个输出乘以一个 scale 参数,然后这个时候的权值是(-1,0,1),以此来减少了乘法器的数目,至于为什么减少跟 BNN 是一样的道理。

DoReFa-Net

  • DoReFa-Net: Training Low Bitwidth Convolutional Neural Networks with Low Bitwidth Gradients 

Face++ 团队在 16 年 6 月提出的 Dorefa-Net 和上面两种量化方法思路也是比较接近,但 DoReLa-Net 对比例因子的设计更为简单,这里并没有针对卷积层输出的每一个过滤映射计算比例因子,而是对卷积层的整体输出计算一个均值常量作为比例因子。这样的做法可以简化反向运算,因为在他们反向计算时也要实现量化。 

文章首先概述如何利用 DoReFa-Net 中的比特卷积内核,然后详细说明量化权值,激活和梯度以低比特数的方法。 

和之前 BNN 的点积方法一样,DoReFa 也采用了这种简化的点积方式。

对于定点数 x 和 y,可以得到下面的公式:

同样为了规避 0 梯度的问题,采用了直通估计(STE):

对于权重二值化的梯度回传,采用下面的方法,即二值化乘比例因子,回传时直接跳过二值化。

比特数 k 大于 1 的梯度回传,需要先对参数 clip 到 [0,1] 之间:

由于二值化输出会降准确率,所以采用 k-bit 量化(k>1),这里的 r 也要经过 clip。

DoReFa 的梯度量化方法比较复杂,因为梯度是无界的,并且可能具有比隐层输出更大的值范围。我们可以通过使可微分非线性函数传递值来将隐层输出范围映射到 [0,1]。 但是,这种构造不适用于渐变。 文章设计了以下用于梯度 k 位量化的函数,这里 dr 是 r 对损失函数 C 的偏导。

为了补偿量化梯度带来的潜在偏差,在 clip 后的结果增加了一个高斯噪声

梯度的量化仅在回程中完成,因此文章在每个卷积层的输出上应用以下 STE:

最终得到了 DoReFa-net 的算法,这里对第一层和最后一层不做量化,因为输入层就图像任务来说通常是 8-bit 的数据,做低比特量化会对精度造成很大的影响,输出层一般是一些 one-hot 向量,所以一般对输出层也保持原样,除非做特殊的声明。

DoReFa-net 为了进一步节省资源将 3,4,6 步放在一起做,将 11,12 步融合在一起,节省了中间步骤的全精度数储存消耗的资源。

DoReFa-Net 分别对 SVHN 和 ImageNet 进行了实验,准确率如下:

net = tl.layers.InputLayer(x, name='input') net = tl.layers.DorefaConv2d(net, 1, 3, 32, (5, 5), (1, 1), padding='SAME', b_init=None, name='bcnn1')  #pylint: disable=bare-except net = tl.layers.MaxPool2d(net, (2, 2), (2, 2), padding='SAME', name='pool1') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn1') net = tl.layers.DorefaConv2d(net, 1, 3, 64, (5, 5), (1, 1), padding='SAME', b_init=None, name='bcnn2')  #pylint: disable=bare-except net = tl.layers.MaxPool2d(net, (2, 2), (2, 2), padding='SAME', name='pool2') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn2')  net = tl.layers.FlattenLayer(net) net = tl.layers.DorefaDenseLayer(net, 1, 3, 256, b_init=None, name='dense') net = tl.layers.BatchNormLayer(net, act=tl.act.htanh, is_train=is_train, name='bn3') net = tl.layers.DenseLayer(net, 10, b_init=None, name='bout') net = tl.layers.BatchNormLayer(net, is_train=is_train, name='bno') 

上面是 TensorLayer 提供的 DoReFa-Net 的 MNIST测试代码,需要注意的是不同于DoReFa-Net,我们的实现默认梯度为 32bits 来尽量获得更高的训练准确率,而且在实际的硬件前向配置中其实是不需要梯度信息的。

压缩算法局限性

目前的压缩算法是存在一些局限性的,最主要的问题还是准确率,论文中为了数据好看往往是选择传统的神经网络结构比如 AlexNet,VGG 作为测试对象,而这种网络一般是比较冗余的。

如果想把参数压缩方案和其他一些方案结合,比如说下面讲到的一些 SqueezeNet,MobileNets,ShuffleNet 结合起来,会对准确率造成比较大的影响。原因可以归为参数压缩算法其实是一个找次优解的问题,当网络冗余度越小,解越不好找。所以,目前的高精度压缩算法只适合于传统的有很多冗余的网络

更多加速方法

理论上来讲,量化模型是通往高速神经网络最佳的方法,不过由于种种问题,如实现难度大、准确性不稳定,使用门槛非常大,所以除了量化模型外,目前有很多更加常用的模型加速方法: 

  • A Survey of Model Compression and Acceleration for Deep Neural Networks (end of 2017) 

这是 2017 年底的一篇 survey。

有基于 Pruning 的:

  • Channel Pruning for Accelerating Very Deep Neural Network

也有基于改变卷积方式的,这是目前最常用的方法:

  • SqueezeNet: AlexNet-level accuracy with 50x fewer parameters and <0.5MB model size 

  • MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications 

  • ShuffleNet: An Extremely Efficient Convolutional Neural Network for Mobile Devices 

传送门:纵览轻量化卷积神经网络:SqueezeNet、MobileNet、ShuffleNet、Xception

值得注意的是,当 TensorLayer 和 Keras 使用完全相同的 MobileNet 时,TensorLayer 的速度是后者的 3 倍(Titan XP 上测试),大家可以试试。

关于AI芯片

关于硬件实现,这里要推荐一篇非常好的survey:

https://ift.tt/2nkOX9o

大家看完这篇文章会对目前最先进的神经网络硬件加速架构有所了解。

由于目前基于 PC 平台的神经网络加速一定程度上不能满足需要,开发基于硬件例如 FPGA 的硬件加速平台显得很有必要。其实硬件加速神经网络前向运算的最主要的任务就是完成卷积优化,减少卷积运算的资源和能源消耗非常核心

卷积优化的主要思路

内存换取时间:如果深度学习中每一层的卷积都是针对同一张图片,那么所有的卷积核可以一起对这张图片进行卷积运算,然后再分别存储到不同的位置,这就可以增加内存的使用率,一次加载图片,产生多次的数据,而不需要多次访问图片,这就是用内存来换时间。

乘法优化:以下图为例,上面是两张图片,右边是卷积核。我们可以把卷积核心展开成一条行,然后多个卷积核就可以排列成多行,再把图像也用类似的方法展开,就可以把一个卷积问题转换成乘法问题。这样就是一行乘以一列,就是一个结果了。这样虽然多做了一些展开的操作,但是对于计算来讲,速度会提升很多。

GPU优化:

1. 了解 IO 访问的情况以及 IO 的性能; 

2. 多线程的并行计算特性; 

3. IO 和并行计算间的计算时间重叠。

对于 NVIDIA 的 GPU 来讲,内存访问是有一些特性的,连续合并访问可以很好地利用硬件的带宽。你可以看到,NVIDIA 最新架构的 GPU,其核心数目可能并没有明显增加,架构似乎也没有太大变化,但在几个计算流处理器中间增加缓存,就提高了很大的性能,为 IO 访问这块儿带来了很大优化。 

Strassen 算法

分析 CNN 的线性代数特性,增加加法减少乘法,这样降低了卷积运算的计算的复杂度,但是这种方法不适合在硬件里面使用,这里就不做详细的介绍了。 

卷积中的数据重用

在软件中的卷积运算,其实我们是在不断的读取数据,进行数据计算。也就是说卷积操作中数据的存取其实是一个很大的浪费,卷积操作中数据的重用如下图所示:

那么想办法减少数据的重用,减少数据的存取成为解决卷积计算问题的一个很重要的方面。

目前这样的方法有很多种,最主要的方法包括以下几种: 

权重固定:最小化权重读取的消耗,最大化卷积和卷积核权重的重复使用; 

输出固定:最小化部分和 R/W 能量消耗,最大化本地积累; 

NLR (No Local Reuse):使用大型全局缓冲区共享存储,减少 DRAM 访问能耗; 

RS:在内部的寄存器中最大化重用和累加,针对整体能源效率进行优化,而不是只针对某种数据类型。 

下表是在 45NM CMOS 的基础上对于不同的操作的能耗进行的统计。对 32 位的各种操作的能耗进行统计,可以看到从 DRAM 里面存取数据的能量消耗是最大的。是 32 位整型数据进行加法的能量消耗的 6400 倍。那么,从数据存取角度考虑卷积的优化就显得尤为必要了。

可行性分析

在进行设计之前先对设计的可行性进行分析,分析过程包括卷积运算可实现性分析、卷积运算并行性分析,卷积的计算公式可以表示成下面的形式:

各个参数的意义在表内详细表示:

在 GPU 中加速时,主要通过将数据最大程度的并行运算,增加了 GPU 的使用率从而加快了速度。但是这种方法在硬件实现的时候是不可行的,因为这种方法本质上没有降低能耗,而 DNN 模型的高能耗和大量的数据是其在可穿戴设备上面进行部署所需要面对的困难。 

下面对一个卷积部分和运算进行分析,如下图 : 

对第一组的 PE 整列,输入的是从 Image 的第 0 行到第 R-1 行的 S 列的数据,同样的对于第二列的 PE 阵列输入的是第 2 行到第 R 的 S 列的数据。每一列的 PE 计算得到一个最终的 Psum 的结果,那么如果设置 PE 阵列的列数为 N 的话,每次我们就可以计算得到连续的 N 个部分和的结果。 

不断更新 PE(process element,即处理单元)中 Image 缓冲区的数据,就可以模拟卷积在水平方向上面的滑动,不断更新整个 PE 阵列的数据输入,就可以模拟卷积窗在垂直方向上面的滑动,最终完成整个卷积运算的实现。

对应的卷积运算公式的细节在图中已经给出了,每一组 PE 产生一个部分和的结果的话,那么增加 PE 阵列的组数,就可以一次性产生多个部分和计算结果,这里的组数就是并行度。 

上面的内容简单论证用数据重用的方式实现卷积运算的可行性,至于实现的具体数据流,还有相对用的系统的架构。

压缩算法在实际硬件芯片的应用

其实压缩算法应用硬件芯片非常简单,就是简单的将硬件芯片原来使用的乘法器进行替换,如果是 BNN,参数只有两种情形,那么如果参数为 1 的时候,直接通过,不计算,如果参数为 -1 的时候,翻转最高位即可。 

同理三值化中增加了一个 0 参数,这个可以直接跳过不进行计算。至于参数为(-2,-1,0,1,2)的情形,参数为 2 时就增加了一个移位运算,参数为 -2 的时候增加了一个最高位的翻转。 

如果是 DoReFaNet,权值和输出都固定在一定的种类内部,那么他们的乘积情形也只有一定的种类,这个时候相当于把乘法器变成了一个寻址操作,每次乘法只需要在 LUT(look-up table,查找表)里面寻找到正确的结果读出即可。

]]> 原文: https://ift.tt/2kFNc2J
RSS Feed

机器知心

IFTTT

再谈最小熵原理:“飞象过河”之句模版和语言结构 | 附开源NLP库

在前一文从无监督构建词库看「最小熵原理」,套路是如何炼成的中,我们以最小熵原理为出发点进行了一系列的数学推导,最终得到 (2.15) 和 (2.17) 式,它告诉我们两个互信息比较大的元素我们应该将它们合并起来,这有利于降低"学习难度"。于是利用这一原理,我们通过邻字互信息来实现了词库的无监督生成。

由字到词、由词到词组,考察的是相邻的元素能不能合并成一个好"套路"。可是套路为什么非得要相邻的呢?

当然不一定相邻,我们学习语言的时候,不仅仅会学习到词语、词组,还要学习到"固定搭配",也就是说词语怎么运用才是合理的,这是语法的体现,是本文所要探究的,希望最终能达到一定的无监督句法分析的效果。

由于这次我们考虑的是跨邻词的语言关联,因此我给它起个名字为"飞象过河",正是:"套路宝典"第二式——"飞象过河"

语言结构

对于大多数人来说,并不会真正知道什么是语法,他们脑海里就只有一些"固定搭配"、"定式",或者更正式一点可以叫"模版"。大多数情况下,我们是根据模版来说出合理的话来。而不同的人的说话模版可能有所不同,这就是个人的说话风格,甚至是"口头禅"。

句模版

比如,"X 的 Y 是什么"就是一个简单的模版,它有一些明确的词语"的"、"是"、"什么",还有一些占位符 X、Y,随便将 X 和 Y 用两个名词代进去,得到的就是合乎语法的句子(合不合事实,那是另外一回事了)。这类模版还可以举出很多,"X和Y"、"X的Y"、"X可以Y吗"、"X有哪些Y"、"X是Y还是Z"等等。

▲ 句模版及其相互嵌套示例

当然,虽然可以抽取尽可能多的模版,但有限的模版是无法覆盖千变万化的语言想象的,所以更重要的是基于模版的嵌套使用。比如"X 的 Y 是什么"这个模版,X 可以用模版"A 和 B"来代替,从而得到"(A 和 B)的 Y 是什么"。如此以来,模版相互嵌套,就可以得到相当多句子了。

等价类

接着,有了模版"X 的 Y 是什么?"之后,我们怎么知道 X 和 Y 分别可以填些什么呢? 

刚才我们说"随便用两个名词"代进去,可是按照我们的思路,到现在为止我们也就只会构建词库,我们连什么是"名词"都不知道,更不知道应该把名词填进去。事实上,我们不需要预先知道什么,我们可以通过大料的语料来抽取每个候选位置的"等价类",其中 X 的候选词组成一个词语等价类,Y 的候选词也组成一个词语等价类,等等。

▲ 句模版及等价类的概念

当然,这样的设想是比较理想的,事实上目前我们能获取的生语料情况糟糕得多,但不管怎样,万丈高楼平地起,我们先解决理想情况,实际使用时再去考虑一般情况。

下面我们来逐一探究如何从大量的原始语料获取句模版,并考虑如何识别句子中所用到的句模版,甚至挖掘出句子的层次结构。

生成模版

事实上,有了前一文的构建词库的经验,事实上就不难构思生成句子模版的算法了。 

在构建词库那里,我们的统计对象是字,现在我们的统计对象是词,此外,词语是由相邻的字组成的,但句子模版却未必是由相邻的词组成的(否则就退化为词或词组),所以我们还要考虑跨词共现,也就是 Word2Vec 中的 Skip Gram 模型

有向无环图

有向无环图(Directed Acyclic Graph,DAG)其实是 NLP 中经常会遇到的一个图论模型。事实上,一元分词模型也可以直接抽象为有向无环图上的最短路径规划问题。而这里的候选模版集构建,也需要用到有向无环图。 

因为考虑了 Skip Gram 模型,因此我们可以把句子内比较"紧凑"(互信息比较大)的"词对"连接起来,用图论的角度看,这就构成了一个"有向无环图":

我们直接将图上的所有路径都取出来,如果跨过了相邻节点,那么就插入一个占位符(下面全部用 X 表示占位符),这样就可以得到候选模版集了。比如从上图中,抽取到的候选模版为:

算法步骤

我们可以把上述流程具体描述如下:

1. 将语料按句子切分,并分词; 

2. 选定一个窗口大小 window,从语料中统计每个词的频率 (pa,pb),以及在窗口大小内中任意两词的共现频率 (pab); 

3. 分别设定出现频率的阈值 min_prob 和互信息的阈值 min_pmi; 

4. 遍历所有句子: 

4.1. 对每个句子构建一个图,句子中的词当作图上的点; 

4.2. 句子中窗口内的词对 (a,b),如果满足 pab>min_prob和>mi_pmi,那么就给图上添加一条"a-->b"的有向边; 

4.3. 找出图上所有的路径(孤立点也算路径),作为候选模版加入统计;

5. 统计各个"准模版"的频率,将"准模版"按频率降序排列,取前面部分即可。

这个算法既可以用来句模版的抽取,也可以简单地用来做词组(短语)的抽取,只需要将 window 设为 1。因此它也就基本上包含了前一文所说的词库构建了,所以上述算法是一个一般化的抽取框架

效果演示 

下面是从百度知道的问题集中抽取出来的一些句模版(数字是统计出来的频数,可以忽略):

注意,事实上"X 的 X"、"X 怎么 X"这种两个占位符夹住一个词的模版是平凡的,它只不过是告诉我们这个词可以插入到句子中使用。因此,为了看出效果,我们排除掉这一类模版,得到:

从结果来看,我们的句模版生成算是确实是有效的。因为这些句模版就有助于我们发现语言的使用规律了。比如:

1. "X 吗""X 了""X 怎么样"这些模版的占位符出现在前面,说明这些词可以放在问句的末尾(我们用到的语料是问句);

2. "我 X""求 X""为什么 X""请问 X"等模版的占位符出现在后面,说明这些词可以放到问句的开头;

3. "谢谢""怎么办"这类模版并没有出现占位符,表明它可以单独成句;

4. "X 是 X 意思""X 有哪些 X"等模版则反映了语言的一些固定搭配。

用通用的观点看,这些模版所描述的都是句法级的语言现象。当然,为了不至于跟目前主流的句法分析混淆,我们不妨就称为语言结构规律,或者直接就称为"句模版"。

结构解析

跟分词一样,当构建好句子模版后,我们也需要有算法来识别句子中用到了哪些模版,也只有做到了这一步,才有可能从语料中识别出词语的等价类出来。

回顾分词算法,分词只是一个句子的切分问题,切分出来的词是没有"洞"(占位符)的,而如果要识别句子中用了哪些模版,这些模版是有"洞"的,并且还可能相互嵌套,这就造成了识别上的困难。然而,一旦我们能够完成这个事情,我们就得到了句子的一个层次结构分解,这是非常有吸引力的目标。

投射性假设

为了实现对句子的层次分解,我们首先可以借鉴的是句法分析一般都会使用的"投射性(projective)假设"。

语言的投射性大概意思是指如果句子可以分为几个"语义块",那么这些语义块是不交叉的。也就是说,假如第 1、2、3 个词组成一个语义块、第 4、5 个词组成一个语义块,这种情况是允许的,而第 1、2、4 个词组成一个语义块、第 3、5 个词组成一个语义块,这种情况是不可能的。大多数语言,包括汉语和英语,基本上都满足投射性。

结构假设

为了完成句子的层次结构分解,我们需要对句子的组成结构做更完整的假设。受到投射性假设的启发,笔者认为可以将句子的结构做如下假设: 

1. 每个语义块是句子的一个连续子字符串,句子本身也算是一个语义块; 

2. 每个语义块由一个主的句模版生成,其中句模版的占位符部分也是一个语义块; 

3. 每个单独的词可以看成是一个平凡的句模版,也可以看成是一个最小粒度的语义块。

说白了,这三点假设可以归纳为一句话:每个句子是由句模版相互嵌套生成的

咋看之下这个假设不够合理,但仔细思考就会发现,这个假设已经足够描述大多数句子的结构了。读者可能有疑虑的是"有没有可能并行地使用两个句模版,而不是嵌套"?答案是:应该不会。

因为如果出现这种情况,只需要将"并行"本身视为一个模版就行了,比如将"X 和 X"也视为一个模版,那么"X 和 X"这个模版中的两个语义块就是并行的了,甚至它可以与自身嵌套得到"X 和(X 和 X)"描述更多的并行现象。

也正因为我们对语言结构做了这种假设,所以一旦我们识别出某个句子的最优句模版组合,我们就得到了句子的层次结构——因为根据假设,模版是按照嵌套的方式组合的,嵌套意味着递归,递归就是一种层次树的结构了

分解算法

有了对句子结构的假设,我们就可以描述句模版识别算法了。首先来重述一下分词算法,一元分词算法的思路为:对句子切分成词,使得这些词的概率对数之和最大(信息量之和最小)

它还可以换一种表述:找一系列的词来不重不漏地覆盖句子中的每个字,使得这些词的概率对数之和最大(信息量之和最小)

以往我们会认为分词是对句子进行切分,这种等价的表述则是反过来,要对句子进行覆盖。有了这个逆向思维,就可以提出模版识别算法了:

找一系列的句模版来不重、不漏、不交叉地覆盖句子中的每个词,使得这些模版的概率对数之和最大(信息量之和最小)。

当然,这只是思路,在实现过程中,主要难点是对占位符的处理,也就是说,句子中的每个词既代表这个词本身,也可以代表占位符,这种二重性使得扫描和识别都有困难。

而不幸中的万幸是,如果按照上面所假设的语言结构,我们可以转化为一个递归运算:最优的结构分解方案中,主模版下的每个语义块的分解方案也是最优的

▲ 句子的层次结构解析,包含了句模版的嵌套调用

因此我们可以得到算法:

1. 扫描中句子中所有可能出现的模版(通过 Trie 树结构可以快速扫描);

2. 每种分解方案的得分,等于句子的主模版得分,加上每个语料块的最优分解方案的得分。

结果展示 

下面是一些简单例子的演示,是通过有限的几个模版进行的分析,可以看到,的确初步实现了句子的层次结构解析。

+---> (鸡蛋)可以(吃)吗 |     +---> 鸡蛋 |     |     +---> 鸡蛋 |     +---> 可以 |     +---> 吃 |     |     +---> 吃 |     +---> 吗  +---> (牛肉鸡蛋)可以(吃)吗 |     +---> 牛肉鸡蛋 |     |     +---> 牛肉 |     |     +---> 鸡蛋 |     +---> 可以 |     +---> 吃 |     |     +---> 吃 |     +---> 吗  +---> (苹果)的(颜色)是(什么)呢 |     +---> 苹果 |     |     +---> 苹果 |     +---> 的 |     +---> 颜色 |     |     +---> 颜色 |     +---> 是 |     +---> 什么 |     |     +---> 什么 |     +---> 呢  +---> (雪梨和苹果和香蕉)的(颜色)是(什么)呢 |     +---> (雪梨和苹果)和(香蕉) |     |     +---> (雪梨)和(苹果) |     |     |     +---> 雪梨 |     |     |     |     +---> 雪梨 |     |     |     +---> 和 |     |     |     +---> 苹果 |     |     |     |     +---> 苹果 |     |     +---> 和 |     |     +---> 香蕉 |     |     |     +---> 香蕉 |     +---> 的 |     +---> 颜色 |     |     +---> 颜色 |     +---> 是 |     +---> 什么 |     |     +---> 什么 |     +---> 呢 

当然,不能报喜不报忧,也有一些失败的例子:

+---> (我的美味)的(苹果的颜色)是(什么)呢 |     +---> (我)的(美味) |     |     +---> 我 |     |     |     +---> 我 |     |     +---> 的 |     |     +---> 美味 |     |     |     +---> 美味 |     +---> 的 |     +---> (苹果)的(颜色) |     |     +---> 苹果 |     |     |     +---> 苹果 |     |     +---> 的 |     |     +---> 颜色 |     |     |     +---> 颜色 |     +---> 是 |     +---> 什么 |     |     +---> 什么 |     +---> 呢  +---> (苹果)的(颜色)是(什么的意思是什么)呢 |     +---> 苹果 |     |     +---> 苹果 |     +---> 的 |     +---> 颜色 |     |     +---> 颜色 |     +---> 是 |     +---> (什么)的(意思)是(什么) |     |     +---> 什么 |     |     |     +---> 什么 |     |     +---> 的 |     |     +---> 意思 |     |     |     +---> 意思 |     |     +---> 是 |     |     +---> 什么 |     |     |     +---> 什么 |     +---> 呢 

失败的例子我们后面再分析。

文章总结

看到一脸懵逼的,有各种话要吐槽的,还请先看到这一节。

拼图游戏

从词、词组都句模版,我们都像是在玩拼图:拼着拼着发现这两块合在一起效果还行,那么就将它合起来吧。因为将互信息大的项合起来,作为一个整体来看,就有助于降低整体的信息熵,也就能降低整体的学习难度。 

对于句模版,如果在中文的世界里想不通,那么就回顾一下我们在小学、初中时学英语是怎么学过来的吧,那会我们应该学习了很多英语的句模版。

有什么用 

"句模版"算是本文提出的新概念,用它来识别语言结果也算是一种新的尝试。读者不禁要问:这玩意有什么用? 

我想,回答这个问题的最好方式,是引用牛顿的一段话: 

我自己认为,我好像只是一个在海边玩耍的孩子,不时为捡到比通常更光滑的石子或更美丽的贝壳而欢欣,而展现在我面前的是完全未被探明的真理之海。

我引用这段话是想表明,做这个探究的最根本原因,并不是出于某种实用目的,而是为了纯粹地探究自然语言的奥秘。 

当然,如果与此同时,研究出来的结果能具备一定的应用价值,那就更加完美了。从现在的结果来看,这种应用价值可能是存在的。

因为我们在 NLP 中,面对的句子千变万化,但事实上"句式"却是有限的,这也意味着句模版也是有限的,如果有必要,我们可以对各个句模板的占位符含义进行人工标注,这就能将句模板的结构跟常规的句法描述对应起来了。通过有限的句模版来对句子进行(无限的)分解,能让 NLP 可面对的场景更加灵活多变一些。 

也许以往的传统自然语言处理中,也出现过类似的东西,但本文所描述的内容纯粹是无监督的结果,并且也有自洽的理论描述,算是一个比较完整的框架,初步的结果也差强人意,因此值得进一步去思考它的应用价值。

艰难前进 

浏览完这篇文章,读者最大的感觉也许是"一脸懵逼":能再简化一点吗? 

要回答这个问题,就不得不提到:距离这个系列的上一篇文章已经过了一个多月,这篇文章才正式发出,这似乎有点久了?从形式上看,本文只不过是前文的简单推广:不就是将相邻关联推广到非相邻关联吗? 

的确,形式上确实如此。但为了将这个想法推广至同时具备理论和实用价值,却并不是那么简单和顺畅的事情。比如,在句模版生成时,如何不遗漏地得到所有的候选模版,这便是一个难题;其次,在得到句模版(不管是自动生成还是人工录入)后,如何识别出句子中的句模版,这更加艰难了,不论在理论思考还是编程实现上,都具有相当多的障碍,需要对树结构、递归编程有清晰的把握。我也是陆陆续续调试了半个多月,才算是把整个流程调通了,但估计还不完备。 

所以,你看得一脸懵逼是再正常不过了,我自己做完、写完这篇文章,还感觉很懵呢。

改进思路

在结果结果展示一节中,我们也呈现一些失败的例子。事实上,失败的例子可能还更多。 

我们要从两个角度看待这个事情。一方面,我们有成功的例子,对应纯粹无监督挖掘的探索,我们哪怕只能得到一小部分成功的结果,也是值得高兴的;另外一方面,对于失败的例子,我们需要思考失败的原因,并且考虑解决方案。 

笔者认为,整体的句模版思路是没有问题的,而问题在于我们没有达到真正的语义级别的理解。比如第一个失败的例子,结果是:(我的美味)的(苹果的颜色)是(什么)呢

我们能说这个分解完全错吗?显然不是,严格来讲,这种分解在语法上并没有任何错误,只是它不符合语义,不符合我们的常识。因此,并非是句模版的错,而是还不能充分地结合语义来构建句模版。 

回顾目前主流的句法分析工作,不管是有监督的还是无监督的,它们基本上都要结合"词性"来完成句法分析。所以这给我们提供了一个方向:最小熵系列下一步的工作就是要探究词语的聚类问题,以便更好地捕捉词义和语言共性。

基于最小熵原理的NLP库:NLP Zero

陆陆续续写了几篇最小熵原理的文章,致力于无监督做 NLP 的一些基础工作。为了方便大家实验,把文章中涉及到的一些算法封装为一个库,供有需要的读者测试使用。 

由于面向的是无监督 NLP 场景,而且基本都是 NLP 任务的基础工作,因此命名为NLP Zero。

地址

Github: https://ift.tt/2H7eLul 

Pypi: https://ift.tt/2sntEEz

可以直接通过:

pip install nlp-zero==0.1.6 

进行安装。整个库纯 Python 实现,没有第三方调用,支持 Python 2.x 和 3.x。

使用

默认分词

库内带了一个词典,可以作为一个简单的分词工具用。

from nlp_zero import *  t = Tokenizer() t.tokenize(u'扫描二维码,关注公众号')

自带的词典加入了一些通过新词发现挖掘出来的新词,并且经过笔者的人工优化,质量相对来说还是比较高的。 

词库构建

通过大量的原始语料来构建词库。 

首先我们需要写一个迭代容器,这样就不用一次性把所有语料加载到内存中了。迭代器的写法很灵活,比如我的数据存在 MongoDB 中,那就是:

import pymongo db = pymongo.MongoClient().weixin.text_articles  class D:     def __iter__(self):         for i in db.find().limit(10000):             yield i['text']

如果数据存在文本文件中,大概就是:

class D:     def __iter__(self):         with open('text.txt') as f:             for l in f:                 yield l.strip() # python2.x还需要转编码

然后就可以执行了。

from nlp_zero import * import logging logging.basicConfig(level = logging.INFO, format = '%(asctime)s - %(name)s - %(message)s')  f = Word_Finder(min_proba=1e-8) f.train(D()) # 统计互信息 f.find(D()) # 构建词库

通过 Pandas 查看结果:

import pandas as pd  words = pd.Series(f.words).sort_values(ascending=False)

直接用统计出来的词库建立一个分词工具:

t = f.export_tokenizer()  t.tokenize(u'今天天气不错')

句模版构建

跟前面一样,同样要写一个迭代器,这里不再重复。 因为构建句模版是基于词来统计的,因此还需要一个分词函数,可以用自带的分词器,也可以用外部的,比如结巴分词。

from nlp_zero import * import logging logging.basicConfig(level = logging.INFO, format = '%(asctime)s - %(name)s - %(message)s')  tokenize = Tokenizer().tokenize # 使用自带的分词工具 # 通过 tokenize = jieba.lcut 可以使用结巴分词  f = Template_Finder(tokenize, window=3) f.train(D()) f.find(D())

通过 Pandas 查看结果:

import pandas as pd  templates = pd.Series(f.templates).sort_values(ascending=False) idx = [i for i in templates.index if not i.is_trivial()] templates = templates[idx] # 筛选出非平凡的模版

每个模版已经被封装为一个类了。 

层次分解

基于句模版来进行句子结构解析。

from nlp_zero import *  # 建立一个前缀树,并加入模版 # 模版可以通过tuple来加入, # 也可以直接通过"tire[模版类]=10"这样来加入 trie = XTrie() trie[(None, u'呢')] = 10 trie[(None, u'可以', None, u'吗')] = 9 trie[(u'我', None)] = 8 trie[(None, u'的', None, u'是', None)] = 7 trie[(None, u'的', None, u'是', None, u'呢')] = 7 trie[(None, u'的', None)] = 12 trie[(None, u'和', None)] = 12  tokenize = Tokenizer().tokenize # 使用自带的分词工具 p = Parser(trie, tokenize) # 建立一个解析器  p.parse(u'鸡蛋可以吃吗') # 对句子进行解析 """输出: >>> p.parse(u'鸡蛋可以吃吗') +---> (鸡蛋)可以(吃)吗 |     +---> 鸡蛋 |     |     +---> 鸡蛋 |     +---> 可以 |     +---> 吃 |     |     +---> 吃 |     +---> 吗 """

为了方便对结果进行调用以及可视化,输出结果已经被封装为一个 SentTree 类。这个类有三个属性:template(当前主模版)、content(当前主模版覆盖的字符串)、modules(语义块的 list,每个语义块也是用 SentTree 来描述)。总的来说,就是按照本文对语言结构的假设来设计的。

]]> 原文: https://ift.tt/2H9lGTT
RSS Feed

机器知心

IFTTT

入职仅一年,套现5000多万后背刺马斯克搬走 Grok 核心代码库!-InfoQ 每周精要894期

「每周精要」 NO. 894 2025/09/06 头条 HEADLINE 入职仅一年,套现 5000 多万搬走 Grok 核心代码库! 业内专家:拥有菜谱不等于能做出同样的菜 精选 SELECTED AI 公司创始人现跑路迪拜! 80% 收入烧广告、假账骗投资人,微...