本文为 gluon 中文教程 的笔记,虽然这个教程内容上主要是推广mxnet和gluon,但是还包含了很多其他的干货,如调参,计算图,延迟执行,符号编程,GPU模型等,所以可以作为对深度学习的一个总的复习与深入。此外,里面的一些课后题目也让自己对相关模型的理解加强了很多。总之,强烈推荐。

预备知识

机器学习简介

成功的机器学习有四个要素:数据、转换数据的模型、衡量模型好坏的损失函数和一个调整模型权重来最小化损失函数的算法。

  1. 数据,从数据中学习结构,规则-主要的数据,包括图像,文本,声音,影像,结构化数据(网页,病例),
  2. 模型,能够将低级特征隐射到高级特征上
  3. LOSS, 判定模型的好坏
  4. 算法,对模型参数进行搜索的方法
  • 回归,回答多少
  • 分类,如OCR,分类一般用交叉熵损失。-层次分类,认为类别之间是有关联的,并非所有的分类错误是相同的,
  • 标注,也可以叫多标签分类,
  • 序列学习,如机器翻译(不只是不定长问题,顺序可能也变了,如日语的习惯),语音转文本(很长的语音帧对应一段文本), tagging and parsing。不定长的输入,seq2seq
  • 搜索与排序,
  • 推荐系统,-更关注个性化,
  • Unsupervised Learning,GAN,贝叶斯图模型阐述一个根本性的描述(各数据的关联),representation learning(罗马 - 意大利 + 法国 = 巴黎,欧式空间表征原对象)。我们可以用少量的参数,准确地捕获数据的相关属性吗?球的轨迹可以很好地用速度,直径和质量准确描述。裁缝们也有一组参数,可以准确地描述客户的身材,以及适合的版型。这类问题被称为子空间估计(subspace estimation) 问题。如果决定因素是线性关系的,则称为主成分分析(principal component analysis)。聚类(用少量原型描述大量数据)。
  • 与环境交互的,离线学习不足,存在协变量转移(covariate shift)当训练和测试数据不同时,所以引入了强化学习,以及对抗学习,明确对环境进行考虑。如Deep-Q以及alphago,RL框架的普适性并没有被夸大。例如,我们可以将任何监督学习问题转化为RL问题。譬如分类问题。我们可以创建一个RL智能体,每个分类都有一个对应的动作;然后,创建一个可以给予奖励的环境,完全等同于原先在监督学习中使用的损失函数。除此以外,RL还可以解决很多监督学习无法解决的问题。例如,在监督学习中,我们总是期望训练使用的输入是与正确的标注相关联。但在RL中,我们并不给予每一次观察这样的期望,环境自然会告诉我们最优的动作,我们只是得到一些奖励。此外,环境甚至不会告诉我们是哪些动作导致了奖励。引出两个问题,探索与利用,是探索新策略还是利用已知的最佳策略。同时需要处理信用分配问题以及部分可观察性问题。— 由于这个框架太笼统,研究了一些特例,当环境得到充分观察时,我们将这类RL问题称为马尔可夫决策过程(Markov Decision Process,简称MDP)。 当状态不依赖于以前的动作时,我们称这个问题为情境式赌博机问题(contextual bandit problem)。当不存在状态时,仅仅是一组可选择的动作,并在问题最初搭配未知的奖励,这是经典的多臂赌博机问题(multi-armed bandit problem)。

ndarry

数据有两种形态

  1. 待读取
  2. 内存中

ndarry提供numpy之外的两个功能

  1. CPU与GPU的异步计算
  2. 自动求导
a = nd.random_normal(shape=3)
a.attach_grad()
with ag.record():
    c = f(a)
c.backward()

注意x.grad保存的是dc/dx,如若c是最终结果的一个子结果,并且我们希望求得dz/dx保存在x.grad中时,我们可以传入头梯度dz/dy的值作为backward()方法的输入参数,系统会自动应用链式法则进行计算。这个参数的默认值是nd.ones_like(y)。当c是向量时候,相当于nd.sum(c).backward()

矩阵的链式法则- 求导变成求Jacobi矩阵(注意向量对向量求导结果不是简单的向量,而是叉乘性质的,写层的时候可以ones_like是因为一般损失函数都是标量),乘法是矩阵乘法即可。

  • 迭代器就是一个函数对象,里面包含yeild即可。

nd.take(x, range),其中range必须为ndarray. 不能直接a[list]。

param[:] = param - lr * param.grad 进行原地修改,修改后params就不带attach_grad()了。

==softmax==

先softmax在crossentropy会有数值稳定问题,所以最好用softmaxentropyloss

==proxychains==

如果下不好

proxychains python xx.py

即可

训练数据集和测试数据集里的每一个数据样本都是从同一个概率分布中相互独立地生成出的(独立同分布假设)。独立同分布假设,给定任意一个机器学习模型及其参数,它的训练误差的期望值和泛化误差都是一样的

需要注意训练误差与泛化误差,一般把数据划分为3各部分,train, val, test

优化算法的 weight_decay 就是L2正则化。

trainer = gluon.Trainer(net.collect_params(), 'sgd', {
        'learning_rate': learning_rate, 'wd': weight_decay})

L2正则化有解析解,$\lambda$越大,$W$衰减的越多。

  • 一般情况下,我们推荐把 更靠近输入层的元素丢弃概率设的更小一点。这个试验中,我们把第一层全连接后的元素丢弃概率设为0.2,把第二层全连接后的元素丢弃概率设为0.5。

dropout就是概率乘以0.需要注意的是keep之后还需要进行乘以$\frac{1}{keep_prob}$放大,使得E[dropout(X)]=X. 后面测试的时候dropout就是1. 可以直接看成一个mapping layer.

from mxnet import nd

def dropout(X, drop_probability):
    keep_probability = 1 - drop_probability
    assert 0 <= keep_probability <= 1
    # 这种情况下把全部元素都丢弃。
    if keep_probability == 0:
        return X.zeros_like()

    # 随机选择一部分该层的输出作为丢弃元素。
    mask = nd.random.uniform(
        0, 1.0, X.shape, ctx=X.context) < keep_probability
    # 保证 E[dropout(X)] == X
    scale =  1 / keep_probability 
    return mask * X * scale

事实上,丢弃法在模拟集成学习。试想,一个使用了丢弃法的多层神经网络本质上是原始网络的子集(节点和边)。举个例子,它可能长这个样子。我们在之前的章节里介绍过随机梯度下降算法:我们在训练神经网络模型时一般随机采样一个批量的训练数 据。丢弃法实质上是对每一个这样的数据集分别训练一个原神经网络子集的分类器。与一般的集成学习不同,这里每个原神经网络子集的分类器用的是同一套参数。因此丢弃法只是在模拟集成学习。
我们刚刚强调了,原神经网络子集的分类器在不同的训练数据批量上训练并使用同一套参数。因此,使用丢弃法的神经网络实质上是对输入层和隐含层的参数做了正则化:学到的参数使得原神经网络不同子集在训练数据上都尽可能表现良好。

对每一个batch训练一个子网络,只是网络共享参数。

float(‘inf’)

k-fold 进行调参,丢进去所有的数据,进行一组参数的鲁棒性测试。

  • super(MLP, self).init(**kwargs):这句话调用nn.Block的init函数,它提供了prefix(指定名字)和params(指定模型参数)两个参数。我们会之后详细解释如何使用。
  • self.name_scope():调用nn.Block提供的name_scope()函数。nn.Dense的定义放在这个scope里面。它的作用是给里面的所有层和参数的名字加上前缀(prefix)使得他们在系统里面独一无二。默认自动会自动生成前缀,我们也可以在创建的时候手动指定。推荐在构建网络时,每个层至少在一个name_scope()里。
print('default prefix:', net2.dense0.name)

net3 = MLP(prefix='another_mlp_')
print('customized prefix:', net3.dense0.name)

nn.Block主要提供这个东西
存储参数
描述forward如何执行
自动求导
nn.Sequential是一个nn.Block容器,它通过add来添加nn.Block。它自动生成forward()函数,其就是把加进来的nn.Block逐一运行。

现在我们知道了nn下面的类基本都是nn.Block的子类,他们可以很方便地嵌套使用。所以在sequential里直接add…()

  • 看源码后发现原因为: [nn.Dense(256), nn.Dense(128), nn.Dense(64)] 的 type 是 list, 而不是 Block, 这样就不会被自动注册到 Block 类的 self._children 属性, 导致 initialize 时在 self._children 找不到神经元, 无法初始化参数.
  • 当执行 self.xxx = yyy 时, setattr 方法会检测 yyy 是否为 Block 类型, 如果是则添加到 self._children 列表中
  • 当执行 initialize() 时, 会从 self._children 中找神经元. 所以在block里定义的block不能是list

  • block 有weight, bias参数,是parameter类,可以获得data, grad, dtype, grad_req, zero_grad, reset_ctx, name, shape, set_data, lr_mult, wd_mult, list_ctx, list_data, list_grad

  • 我们也可以通过collect_params来访问Block里面所有的参数(这个会包括所有的子Block)。它会返回一个名字到对应Parameter的dict。既可以用正常[]来访问参数,也可以用get(),它不需要填写名字的前缀。

  • 我们一直在使用默认的initialize来初始化权重(除了指定GPU ctx外)。它会把所有权重初始化成在[-0.07, 0.07]之间均匀分布的随机数。我们可以使用别的初始化方法。例如使用均值为0,方差为0.02的正态分布, params.initialize(init=init.Normal(sigma=0.02), force_reinit=True). from mxnet import init, init=init.One(), force_reinit=True, initAPI,
class MyInit(init.Initializer):
    def __init__(self):
        super(MyInit, self).__init__()
        self._verbose = True
    def _init_weight(self, _, arr):
        # 初始化权重,使用out=arr后我们不需指定形状
        print('init weight', arr.shape)
        nd.random.uniform(low=5, high=10, out=arr)
    def _init_bias(self, _, arr):
        print('init bias', arr.shape)
        # 初始化偏移
        arr[:] = 2

# FIXME: init_bias doesn't work
params.initialize(init=MyInit(), force_reinit=True)
print(net[0].weight.data(), net[0].bias.data())
  • 真正的初始化发生在我们看到数据时,net就定下来了
  • 如果不想延后,in_units=5
  • params = net[-1].params, 注意block.params是包含两个parameter类的字典。可以进行参数共享。
  • weight_initializer = ‘MyInit1’, bias_initializer = ‘xxx’

序列化,包括数据ndarray,模型

nd.save(filename, [x,y])

a,b = nd.load(filename)

pickle也是一种序列化的包

不论是字典还是什么 都可以用nd.save()

nn.block自带了.save_params(filename.params).load_params(params, mx.cpu()),load的时候需要指定ctx

一般常用的是32位浮点数

不需要参数直接forward,如果需要参数需要定义gluon.Parameter("xxx", shape=()) 一个参数需要名字与形状。之后.initialize(),就得到了一个雷

  • 通常自定义层的时候我们不会直接创建Parameter,而是用过Block自带的一个ParamterDict类型的成员变量params,顾名思义,这是一个由字符串名字映射到Parameter的字典。pd = gluon.ParameterDict(prefix="block1_") pd.get("exciting_parameter_yay", shape=(3,3))

这个字典如果不存在的话,会新建一个parameter

直接在self.params里进行get即可

  • 在init里进行参数初始化,在forward里完成计算。
  • 参数get(allow_deferred_init=, init=xxx)

gpu

  • MXNet使用Context来指定使用哪个设备来存储和计算。默认会将数据开在主内存,然后利用CPU来计算,这个由mx.cpu()来表示。GPU则由mx.gpu()来表示。注意mx.cpu()表示所有的物理CPU和内存,意味着计算上会尽量使用多有的CPU核。但mx.gpu()只代表一块显卡和其对应的显卡内存。如果有多块GPU,我们用mx.gpu(i)来表示第i块GPU(i从0开始)。
  • 我们可以通过copyto和as_in_context来在设备直接传输数据。y = x.copyto(mx.gpu(1)), z = x.as_in_context(mx.gpu()), (x, y, z), 最好使用as_in_context
  • 计算会在数据的context上执行。所以为了使用GPU,我们只需要事先将数据放在上面就行了。结果会自动保存在对应的设备上
  • 不同GPU之间的运算会出错
  • 注意所有计算要求输入数据在同一个设备上。不一致的时候系统不进行自动复制。这个设计的目的是因为设备之间的数据交互通常比较昂贵,我们希望用户确切的知道数据放在哪里,而不是隐藏这个细节。下面代码尝试将CPU上x和GPU上的y做运算。
  • net.initialize(ctx=mx.gpu()),初始化的时候指定ctx即可既定数据放在哪。
  • 计算必须放在同一环境下,多GPU可以[mx.gpu(1), mx.gpu(2)]
  • copyto不会修改原来的id,as_in_context一样

CNN

  • 记住一个参数就是4b,10^9就是 4GB了
  • 输入输出数据格式是 batch x channel x height x width,这里batch和channel都是1
  • 权重格式是 output_channels x in_channels x height x width,这里input_filter和output_filter都是1。
  • nd.Convolution(data, w, b, kernel=w.shape[2:], num_filter=w.shape[1], stride=(2,2), pad=(1,1))
  • nd.Pooling(data=data, pool_type=”max”, kernel=(2,2))
  • 定义ctx
  • 卷积模块通常是“卷积层-激活层-池化层”。然后转成2D矩阵输出给后面的全连接层
  • 一个ndarray有context属性。
  • W3 = nd.random_normal(shape=(1250, 128), scale=weight_scale, ctx=ctx)
  • 注意nd.flatten()是Flattens the input array into a 2-D array by collapsing the higher dimensions
  • 例如 n×mn×m 和 m×km×k 的矩阵乘法需要浮点运算2nmk次。
  • nn.Conv2D(channels=20, kernel_size=5, activation=’relu’)
  • net.add(nn.Activation(activation=’relu’))
    net.add(nn.MaxPool2D(pool_size=2, strides=2))
  • net.initialize(ctx=ctx)

batchnorm

  • SGD迭代的时候目标值可能不稳定,数学的解释是,如果把目标函数 ff 根据参数 ww 迭代(如 f(w−η∇f(w))f(w−η∇f(w)) )进行泰勒展开,有关学习率 ηη 的高阶项的系数可能由于数量级的原因(通常由于层数多)而不容忽略。然而常用的低阶优化算法(如梯度下降)对于不断降低目标函 数的有效性通常基于一个基本假设:在以上泰勒展开中把有关学习率的高阶项通通忽略不计。
  • 批量归一化试图对深度学习模型的某一层所使用的激活函数的输入进行归一化:使批量呈标准正态分布(均值为0,标准差为1)。
  • batchnorm两个参数,拉升与偏移
  • 对线性层直接对batch求均值,如果是对conv的输入,那么需要对每个channel进行求平均,
  • 形状不一样的时候ndarray会自动broadcast
  • mean(keepdims=True)
  • 把batchnorm看做dropout一样的mapping层,在训练的时候保存moving_mean,
  • 在函数里,xx[:]就可以进行原地修改,跳出函数也没事
  • batchnorm的参数是对应到channel的,所以形状是channel的大小,同理conv的bias也是对应的channel,所以形状是filter.shape[1
  • batch_norm(h1_conv, gamma1, beta1, is_training,
                     moving_mean1, moving_variance1)]
    
  • 其实是新版把 nd.random_normal重命名到了nd.random.normal,为了跟numpy一致。推荐使用后者
  • batchnorm主要是让收敛变快,但对acc影响不大。后者需要靠教程里讨论的别的技术
  • 一般batchnorm加在conv后面,在加activation,

网络结构

  • alex->vgg->NIN->google-net->resnet->densenet
  • 取而代之,研究者们通过勤劳,智慧和黑魔法生成了许多手工特征。通常的模式是
  • net.add(nn.BatchNorm(axis=1)),要标明axis。
  1. 找个数据集
  2. 用一堆已有的特征提取函数生成特征
  3. 把这些特征表示放进一个简单的线性模型(当时认为的机器学习部分仅限这一步)特征为王
  • ImageNet数据库后得以焕然。它包含了1000类,每类有1000张不同的图片
  • 计算资源的匮乏,这也是为什么上世纪90年代左右基于凸优化的算法更被青睐的原因。毕竟凸优化方法里能很快收敛,并可以找到全局最小值和高效的算法。
  • GPU为游戏而生,尤其是为了大吞吐量的4x4矩阵和向量乘法,用于基本的图形转换,与此同时,NVIDIA和ATI开始对GPU为通用计算做优化,并命名为GPGPU(即通用计算GPU)。
  • CPU,现代笔记本电脑可以有四核,而高端服务器也很少超过64核,就是因为这些核心并不划算。GPU通常有一百到一千个小处理单元组成(具体数值在NVIDIA,ATI/AMD,ARM和其他芯片厂商的产品间有所不同),这些单元通常被划分为稍大些的组(NVIDIA把这称作warps)。虽然它们每个处理单元相对较弱,运行在低于1GHz的时钟频率,庞大的数量使得GPU的运算速度比CPU快不止一个数量级。比如,NVIDIA最新一代的Volta运算速度在特别的指令上可以达到每个芯片120 TFlops,(更通用的指令上达到24 TFlops),而至今CPU的浮点数运算速度也未超过1 TFlop。这其中的原因很简单: 首先,能量消耗与时钟频率成二次关系,所以同样供一个运行速度是4x的CPU核心所需的能量可以用来运行16个GPU核心以其1/4的速度运行,并达到16 x 1/4 = 4x的性能。此外,GPU核心结构简单得多(事实上有很长一段时间他们甚至都还不能运行通用的代码),这使得他们能量效率很高。最后,很多深度学习中的操作需要很高的内存带宽,而GPU以其十倍于很多CPU的内存带宽而占尽优势。

alex

  • 回到2012年,Alex Krizhevsky和Ilya Sutskever实现的可以运行在GPU上的深度卷积网络成为重大突破。他们意识到卷积网络的运算瓶颈(卷积和矩阵乘法)其实都可以在硬件上并行。使用两个NVIDIA GTX580和3GB内存,他们实现了快速的卷积。他们足够好的代码cuda-convnet使其成为那几年里的业界标准,驱动着深度学习繁荣的头几年。
  • init=init.Xavier()

vgg

  • VGG的一个关键是使用很多有着相对小的kernel($3\times 3$)的卷积层然后接上一个池化层,之后再将这个模块重复多次。下面我们先定义一个这样的块:architecture = ((1,64), (1,128), (2,256), (2,512), (2,512)),全部用的是3x3,唯一的不同是连几个和几个channel。block->stack, alexnet爆炸慢 224x224。。。vgg是96x96.
from mxnet.gluon import nn
from mxnet import nd
from mxnet import gluon
from mxnet import init
import sys
sys.path.append('..')
import utils

net = nn.Sequential()
with net.name_scope():
    net.add(
        nn.Conv2D(channels=32, kernel_size=3, padding=1),
        nn.BatchNorm(axis=1),
        nn.Activation(activation='relu'),
        nn.Dropout(0.2),
        nn.Conv2D(channels=32, kernel_size=3, padding=1),
        nn.BatchNorm(axis=1),
        nn.Activation(activation='relu'),
        nn.MaxPool2D(pool_size=2),
        nn.Dropout(0.5),

        nn.Conv2D(channels=64, kernel_size=3, padding=1),
        nn.BatchNorm(axis=1),
        nn.Activation(activation='relu'),
        nn.Dropout(0.2),
        nn.Conv2D(channels=64, kernel_size=3, padding=1),
        nn.BatchNorm(axis=1),
        nn.Activation(activation='relu'),
        nn.MaxPool2D(pool_size=2),
        nn.Dropout(0.5),

        nn.Flatten(),

        nn.Dense(256),
        nn.BatchNorm(axis=1),
        nn.Activation(activation='relu'),
        nn.Dropout(0.5),

        nn.Dense(256),
        nn.BatchNorm(axis=1),
        nn.Activation(activation='relu'),
        nn.Dropout(0.5),

        nn.Dense(10)
    )
net.initialize(ctx=ctx, init=init.Xavier())
train_data, test_data = utils.load_data_fashion_mnist(batch_size=512)
loss = gluon.loss.SoftmaxCrossEntropyLoss()
trainer = gluon.Trainer(net.collect_params(),'adam', {'learning_rate': 0.001})  
utils.train(train_data, test_data, net, loss, trainer, ctx, num_epochs=100)
  • dropout一般在最后用,batchnorm在激活之前用。

NIN

  • NiN提出只对通道层做全连接并且像素之间共享权重来解决上述两个问题。就是说,我们使用kernel大小是 1×1 的卷积。下面代码定义一个这样的块,它由一个正常的卷积层接上两个kernel是 1×11×1 的卷积层构成。后面两个充当两个全连接层的角色。也就是用1x1的卷积来替代全连接。解决conv-4d, lienar-2d,以及4d-2d后参数过多。相当于用1x1的卷积来代替全连接层,最后用一个全局平均池化得到类别分数。
  • 这种“一卷卷到底”最后加一个平均池化层的做法也成为了深度卷积神经网络的常用设计。
  • 最后flatten一下就ok.
  • 相当于把一个conv转换成了convx-conv1-conv1,
  • NIN的motivation:CNN也可以看成一种广义线性模型。CNN的抽象能力有两种,一种是多通道来抽取不同的特征,二是stacking来对前面的特征进行更高层的抽象,这两种方法都会使得参数和计算量非常快的增大,所以用1x1的卷积来完成上面两个操作,第一个1x1可以看做对前面的特征进行线性组合(变相的全连接层),再加一个activation,但是这样和正常cnn无区别,所以在加入一个,进行更高层度的非线性变换,以此在不太增加计算量和参数量的同时增大模型的抽象能力,最重要的是最后一个1x1在加global-average-pooling代替全连接层,

googlenet

  • googlelenet加入了inception单元,并行卷积,
  • (x-1)/2就是same-padding需要加入的padding数目。
  • 注意transpose和reshape

resnet

  • resnet,解决了网络难训练的问题,之前的方案是
  1. 逐层训练,效果不好
  2. 更宽的层,如google-net,但是效果不好
  • 通过跨层来解决梯度传回的时候变小的问题。可以看成两个网络的加和,一边容易训练,一边抓住没有找到的特征,也就是残差。
  • resnet全3X3,如VGG一样,但是加了batchnorm
  • 一个残差单元就是两个3x3的conv组成,大小可以不变或者缩小为一半,如果缩小成一半或者输入输出通道大小不一样,则利用1x1的卷积对x进行采样,采样成对应的通道。采用的类似vgg一样层数不断增加,第一层7x7大卷积加max降低后面的参数,之后用residual(全卷积,只有conv-batch-relu)堆起来。conv-max->residual, 64x2->128x2->256x2->256x2->avgconv3x3(相当于global-avg)->dense-class,每当通道数倍增的时候,特征图倍减,最后类似NIN的global-avg-pooling->dense。
  • resnet后面的论文还讨论了将CONV-BN-RELU的残差单元(一个残差单元包括两个这样的操作)修改成BN-RELU-CONV, which makes training easier and improves generalization,注意后面加在一起然后relu才是结果。可以将relu-bn都看做激活函数,将post-activation修改“pre-activation”,将bn+relu看做权重激活。
  • bottleneck -》

densenet

  • Dense-NET用的不是加法,而是concat,可以看到DenseNet里来自跳层的输出不是通过加法(+)而是拼接(concat)来跟目前层的输出合并。因为是拼接,所以底层的输出会保留的进入上面所有层。这是为什么叫“稠密连接”的原因
  • DenseNet的卷积块使用ResNet改进版本的BN->Relu->Conv。每个卷积的输出通道数被称之为growth_rate,这是因为假设输出为in_channels,而且有layers层,那么输出的通道数就是in_channels+growth_rate*layers。
  • 所以比conv-batch-relu更好的是batch-relu-conv?
  • nd.concat(x, out, dim=1)
  • 因为使用拼接的缘故,每经过一次过渡块输出通道数可能会激增。为了控制模型复杂度,这里引入一个过渡块,它不仅把输入的长宽减半,同时也使用 1×11×1 卷积来改变通道数。transition-block用1x1的卷积变换通道,然后用2x2的avg-pool来进行特征图减半。
  • DenseNet的主体就是交替串联稠密块和过渡块。它使用全局的growth_rate使得配置更加简单。过渡层每次都将通道数减半。下面定义一个121层的DenseNet。
  • 和resnet一样,最开始要有一个7x7的conv, kernel_size=7,strides=2, padding=3. 再加一个maxconv(pool_size=3, strides=2, padding=1),相当于除以4,通道数上升到64,之后growth_rate=32, [6,12,24,16]进行搭建block。用过渡层将当前层数减半。最后一层不加conv。直接batch->act->avgpooling->dense
  • 在最后的操作前加个batch->relu效果很好。。。在pooling。在dense.
  • DesNet论文中提交的一个优点是其模型参数比ResNet更小,想想为什么?
def transition_block(channels):
    out = nn.Sequential()
    out.add(
        nn.BatchNorm(),
        nn.Activation('relu'),
        nn.Conv2D(channels, kernel_size=1),
        nn.AvgPool2D(pool_size=2, strides=2)
    )
    return out
def dense_net():
    net = nn.Sequential()
    # add name_scope on the outermost Sequential
    with net.name_scope():
        # first block
        net.add(
            nn.Conv2D(init_channels, kernel_size=7,
                      strides=2, padding=3),
            nn.BatchNorm(),
            nn.Activation('relu'),
            nn.MaxPool2D(pool_size=3, strides=2, padding=1)
        )
        # dense blocks
        channels = init_channels
        for i, layers in enumerate(block_layers):
            net.add(DenseBlock(layers, growth_rate))
            channels += layers * growth_rate
            if i != len(block_layers)-1:
                net.add(transition_block(channels//2))
        # last block
        net.add(
            nn.BatchNorm(),
            nn.Activation('relu'),
            nn.AvgPool2D(pool_size=1),
            nn.Flatten(),
            nn.Dense(num_classes)
        )
    return net
  • f.read()获得字符列表(str), f.readlines()获得行列表

RNN

RNN生成句子,就是当前的序列标注问题,这部分比较简单,就不过多介绍。

优化算法

sgd

SGD实际上就是梯度下降法,随机指的是随机采样样本一个,随机梯度是原梯度的无偏估计,小批量随机梯度下降是采样一个小批量,小批量随机梯度下降包含了梯度下降和随机梯度下降这两种特殊形式。

# 小批量随机梯度下降。
def sgd(params, lr, batch_size):
    for param in params:
        param[:] = param - lr * param.grad / batch_size

动量法

动量法相当于对$\frac{\mu}{1-\alpha}{\Delta}f_{\beta}(x)$做指数加权移动平均。因此,动量法的每次迭代中,参数在各个方向上移动幅度不仅取决当前梯度,还取决过去各个梯度在各个方向上是否一致。如果一致会加速,不一致会均衡。

# 动量法。
def sgd_momentum(params, vs, lr, mom, batch_size):
    for param, v in zip(params, vs):
        v[:] = mom * v + lr * param.grad / batch_size
        param[:] -= v

注意这里要除以batch_size是因为loss的计算是sum而不是mean,计算的梯度需要取平均。

adagrad

在我们之前的优化算法中,无论是梯度下降、随机梯度下降、小批量随机梯度下降还是使用动量法,模型参数中的每一个元素在相同时刻都使用同一个学习率来自我迭代。

Adagrad就是一个在迭代过程中不断自我调整学习率,并让模型参数中每个元素都使用不同学习率的优化算法。

Adagrad的核心思想是:如果模型损失函数有关一个参数元素的偏导数一直都较大,那么就让它的学习率下降快一点;反之,如果模型损失函数有关一个参数元素的偏导数一直都较小,那么就让它的学习率下降慢一点。然而,由于ss一直在累加按元素平方的梯度,每个元素的学习率在迭代过程中一直在降低或不变。所以在有些问题下,当学习率在迭代早期降得较快时且当前解依然不理想时,Adagrad在迭代后期可能较难找到一个有用的解。

# Adagrad算法
def adagrad(params, sqrs, lr, batch_size):
    eps_stable = 1e-7
    for param, sqr in zip(params, sqrs):
        g = param.grad / batch_size
        sqr[:] += nd.square(g)
        div = lr * g / nd.sqrt(sqr + eps_stable)
        param[:] -= div

rmsprop

为了应对adagrad在迭代后期学习率不断衰减这一问题,RMSProp算法对Adagrad做了一点小小的修改。我们先给出RMSProp算法。

RMSProp只在Adagrad的基础上修改了变量s的更新方法:把累加改成了指数加权移动平均。因此,每个元素的学习率在迭代过程中既可能降低又可能升高。

# RMSProp
def rmsprop(params, sqrs, lr, gamma, batch_size):
    eps_stable = 1e-8
    for param, sqr in zip(params, sqrs):
        g = param.grad / batch_size
        sqr[:] = gamma * sqr + (1. - gamma) * nd.square(g)
        div = lr * g / nd.sqrt(sqr + eps_stable)
        param[:] -= div

adadelta

事实上,Adadelta也是一种应对adagrad学习率衰减的方法。有意思的是,它没有学习率参数。

实际上adadelta就是在rmspro在套上一层rmsprop,防止梯度增长过大?

# Adadalta
def adadelta(params, sqrs, deltas, rho, batch_size):
    eps_stable = 1e-5
    for param, sqr, delta in zip(params, sqrs, deltas):
        g = param.grad / batch_size
        sqr[:] = rho * sqr + (1. - rho) * nd.square(g)
        cur_delta = nd.sqrt(delta + eps_stable) / nd.sqrt(sqr + eps_stable) * g
        delta[:] = rho * delta + (1. - rho) * cur_delta * cur_delta
        param[:] -= cur_delta

只有一个参数rho, 缩放与梯度共用。

adam

Adam是一个组合了动量法和RMSProp的优化算法。

和RMS有两个区别

  1. 对梯度输入加了动量
  2. 为了减轻vv和ss被初始化为0在迭代初期对计算指数加权移动平均的影响,做了修正

# Adam。
def adam(params, vs, sqrs, lr, batch_size, t):
    beta1 = 0.9
    beta2 = 0.999
    eps_stable = 1e-8
    for param, v, sqr in zip(params, vs, sqrs):
        g = param.grad / batch_size
        v[:] = beta1 * v + (1. - beta1) * g
        sqr[:] = beta2 * sqr + (1. - beta2) * nd.square(g)
        v_bias_corr = v / (1. - beta1 ** t)
        sqr_bias_corr = sqr / (1. - beta2 ** t)
        div = lr * v_bias_corr / (nd.sqrt(sqr_bias_corr) + eps_stable)
        param[:] = param - div

gluon高级

命令式与符号式编程

优缺点方面。

  • 命令式编程更方便。 当我们在Python里用一个命令式编程库时,我们在写Python代码,绝大部分代码很符合直觉。同样很容易逮BUG,因为我们可以拿到所有中间变量值,我们可以简单打印它们,或者使用Python的debug工具。
  • 符号式编程更加高效而且更容易移植。 之前我们提到在编译的时候系统可以容易的做更多的优化。另外一个好处是可以将程序变成一个与Python无关的格式,从而我们可以在非Python环境下运行。

符号式的框架有

  1. theano
  2. tensorflow

命令式的框架

  1. chainer
  2. pytorch

gluon采用的是混合式,其通过使用HybridBlock或者HybridSequential来构建神经网络。默认他们跟Block和Sequential一样使用命令式执行。当我们调用.hybridize()后,系统会转换成符号式来执行。事实上,所有Gluon里定义的层全是HybridBlock,这个意味着大部分的神经网络都可以享受符号式执行的优势。

注意到只有继承自HybridBlock的层才会被优化。HybridSequential和Gluon提供的层都是它的子类。如果一个层只是继承自Block,那么我们将跳过优化。我们会接下会讨论如何使用HybridBlock。

在教程的benchmark中,hybridize提供近似两倍的加速。

from time import time

def bench(net, x):
    start = time()
    for i in range(1000):
        y = net(x)
    # 等待所有计算完成
    nd.waitall()
    return time() - start

net = get_net()
print('Before hybridizing: %.4f sec'%(bench(net, x)))
net.hybridize()
print('After hybridizing: %.4f sec'%(bench(net, x)))

之前我们给net输入NDArray类型的x,然后net(x)会直接返回结果。对于调用过hybridize()后的网络,我们可以给它输入一个Symbol类型的变量,其会返回同样是Symbol类型的程序。

class HybridNet(nn.HybridBlock):
    def __init__(self, **kwargs):
        super(HybridNet, self).__init__(**kwargs)
        with self.name_scope():
            self.fc1 = nn.Dense(10)
            self.fc2 = nn.Dense(2)

    def hybrid_forward(self, F, x):
        print(F)
        print(x)
        x = F.relu(self.fc1(x))
        print(x)
        return self.fc2(x)

hybrid_forward方法加入了额外的输入F,它使用了MXNet的一个独特的特征。MXNet有一个符号式的API (symbol) 和命令式的API (ndarray)。这两个接口里面的函数基本是一致的。系统会根据输入来决定F是使用symbol还是ndarray。

但它可能的问题是我们损失写程序的灵活性。因为Python的代码只执行一次,而是直接在C++后端执行这个符号式程序,那么使用print来调试,或者使用if和for来做复杂的控制都不可能了。

延迟执行

延迟执行是指命令可以等到之后它的结果真正的需要的时候再执行。和data_iter一样。这样的主要好处是在执行之前系统可以看到后面指令,从而有更多机会来对程序进行优化。例如如果a在被使用前被重新赋值了,那么我们可以不需要真正执行第一条语句。

通过计算图系统可以知道所有计算的依赖关系,有了它系统可以选择将没有依赖关系任务同时执行来获得性能的提升。

不管使用什么前端,MXNet的程序执行主要都在C++后端。前端只是把程序传给后端。后端有自己的线程来不断的收集任务,构造计算图,优化,并执行。本章我们介绍后端优化之一:延迟执行。

事实上,只要数据是保存在NDArray里,而且使用MXNet提供的运算子,后端将默认使用延迟执行来获取最大的性能。

除了前面介绍的print外,我们还有别的方法可以让前端线程等待直到结果完成。我们可以使用nd.NDArray.wait_to_read()等待直到特定结果完成,或者nd.waitall()等待所有前面结果完成。后者是测试性能常用方法。

任何方法将内容从NDArray搬运到其他不支持延迟执行的数据结构里都会触发等待,例如asnumpy(), asscalar()

from mxnet import autograd

mem = get_mem()

total_loss = 0
for x, y in get_data():
    with autograd.record():
        L = loss(y, net(x))
    total_loss += L.sum().asscalar()
    L.backward()
    trainer.step(x.shape[0])

nd.waitall()
print('Increased memory %f MB' % (get_mem() - mem))

batch 0, time 0.000002 sec
batch 10, time 1.710598 sec
batch 20, time 3.694613 sec
batch 30, time 5.586327 sec
batch 40, time 7.433568 sec
batch 50, time 9.309987 sec
Increased memory -122.308000 MB
from mxnet import autograd

mem = get_mem()

total_loss = 0
for x, y in get_data():
    with autograd.record():
        L = loss(y, net(x))
    L.backward()
    trainer.step(x.shape[0])

nd.waitall()
print('Increased memory %f MB' % (get_mem() - mem))

batch 0, time 0.000003 sec
batch 10, time 0.013643 sec
batch 20, time 0.024703 sec
batch 30, time 0.035669 sec
batch 40, time 0.046614 sec
batch 50, time 0.057505 sec
Increased memory 235.580000 MB

延后执行使得系统有更多空间来做性能优化。但我们推荐每个批量里至少有一个同步函数,例如对损失函数进行评估,来避免将过多任务同时丢进后端系统。

这点需要注意,不然批量数据会堆起来到内存一起,这样data_iter就相当于没有用处了。

自动并行

通常一个运算符,例如+或者dot,会用掉一个计算设备上所有计算资源。dot同样用到所有CPU的核(即使是有多个CPU)和单GPU上所有线程。因此在单设备上并行运行多个运算符可能效果并不明显。自动并行主要的用途是多设备的计算并行,和计算与通讯的并行。

如在CPU和GPU两边可以并行。

计算和通讯的并行

在多设备计算中,我们经常需要在设备之间复制数据。例如下面我们在GPU上计算,然后将结果复制回CPU。

from mxnet import cpu

def copy_to_cpu(x):
    """copy data to a device"""
    return [y.copyto(cpu()) for y in x]

start = time()
y = run(x_gpu)
nd.waitall()
print('Run on GPU: %f sec'%(time()-start))

start = time()
copy_to_cpu(y)
nd.waitall()
print('Copy to CPU: %f sec'%(time() - start))
Run on GPU: 1.226394 sec
Copy to CPU: 0.520051 sec
start = time()
y = run(x_gpu)
copy_to_cpu(y)
nd.waitall()
t = time() - start
print('Run on GPU then Copy to CPU: %f sec'%(time() - start))
Run on GPU then Copy to CPU: 1.274243 sec

可以看到总时间小于前面两者之和。这个任务稍微不同于上面,因为运行和复制之间有依赖关系。就是y[i]必须先计算好才能复制到CPU。但在计算y[i]的时候系统可以复制y[i-1],从而获得总运行时间的减少。

多GPU

数据并行目前是深度学习里面使用最广泛的用来将任务划分到多设备的办法。它是这样工作的,假设这里有k个GPU,每个GPU将维护一个模型参数的复制。然后每次我们将一个批量里面的样本划分成k块并分每个GPU一块。每个GPU使用分到的数据计算梯度。然后我们将所有GPU上梯度相加得到这个批量上的完整梯度。之后每个GPU使用这个完整梯度对自己维护的模型做更新。

nvidia-smi

不同设备带上的数据不能直接运算,要copyto后运算,copyto可以是context也可以是一样的数据。

在多GPU时,通常我们需要增加批量大小使得每个GPU能得到足够多的任务来保证性能。但一个大的批量大小可能使得收敛变慢。这时候的一个常用做法是将学习率增大些。

实际使用的时候只需要在batch生成的时候split_load一下就好了,然后分别计算损失,分别backward

计算机视觉

图像增强

常用

  1. 变形,如水平翻转,随机裁剪,缩放,平移
  2. 变色,调整亮度,色度,对比度,加噪声,PCA Jittering
  3. 类别不平衡,Label Shuffling,平衡采样
  4. 通过我们的实验发现,如果你在训练的最后几个epoch,移除数据增强,然后跟传统一样测试,可以提升一点性能
  5. 就是多尺度的训练,多尺度的测试

海康的报告
一段代码
知乎回答

nd.stack(*X)

aug = image.BrightnessJitterAug(.5)
aug = image.HueJitterAug(.5)
# 随机裁剪,要求保留至少0.1的区域,随机长宽比在.5和2之间。
# 最后将结果resize到200x200
aug = image.RandomSizedCropAug((200,200), .1, (.5,2))
# 以.5的概率做翻转
aug = image.HorizontalFlipAug(.5)

def get_transform(augs):
    def transform(data, label):
        # data: sample x height x width x channel
        # label: sample
        data = data.astype('float32')
        if augs is not None:
            # apply to each sample one-by-one and then stack
            data = nd.stack(*[
                apply_aug_list(d, augs) for d in data])
        data = nd.transpose(data, (0,3,1,2))
        return data, label.astype('float32')
    return transform

图片增强可以有效避免过拟合。

迁移学习

通常预训练好的模型由两块构成,一是features,二是classifier。后者主要包括最后一层全连接层,前者包含从输入开始的大部分层。这样的划分的一个主要目的是为了更方便做微调。我们先看下classifer的内容:

from mxnet.gluon.model_zoo import vision as models

pretrained_net = models.resnet18_v2(pretrained=True)


pretrained_net.classifier

HybridSequential(
  (0): BatchNorm(axis=1, eps=1e-05, momentum=0.9, fix_gamma=False)
  (1): Activation(relu)
  (2): GlobalAvgPool2D(size=(1, 1), stride=(1, 1), padding=(0, 0), ceil_mode=True)
  (3): Flatten
  (4): Dense(512 -> 1000, linear)
)

pretrained_net.features[1].params.get('weight').data()[0][0]


from mxnet import init


finetune_net = models.resnet18_v2(classes=2)
finetune_net.features = pretrained_net.features
finetune_net.classifier.initialize(init.Xavier())

gluon-CIFAR 比赛