為什么要開發Gluon的接口
在MXNet中我們可以通過Sybmol
模塊來定義神經網絡,並組通過Module
模塊提供的一些上層API來簡化整個訓練過程。那MXNet為什么還要重新開發一套Python的API呢,是否是重復造輪子呢?答案是否定的,Gluon主要是學習了Keras、Pytorch等框架的優點,支持動態圖(Imperative)編程,更加靈活且方便調試。而原來MXNet基於Symbol來構建網絡的方法是像TF、Caffe2一樣靜態圖的編程方法。同時Gluon也繼續了MXNet在靜態圖上的一些優化,比如節省顯存,並行效率高等,運行起來比Pytorch更快。
更加簡潔的接口
我們先來看一下用Gluon的接口,如果創建並組訓練一個神經網絡的,我們以mnist數據集為例:
import mxnet as mx
import mxnet.ndarray as nd
from mxnet import gluon
import mxnet.gluon.nn as nn
數據的讀取
首先我們利用Gluon的data模塊來讀取mnist數據集
def transform(data, label):
return data.astype('float32') / 255, label.astype('float32')
minist_train_dataset = gluon.data.vision.MNIST(train=True, transform=transform)
minist_test_dataset = gluon.data.vision.MNIST(train=False, transform=transform)
batch_size = 64
train_data = gluon.data.DataLoader(dataset=minist_train_dataset, shuffle=True, batch_size=batch_size)
test_data = gluon.data.DataLoader(dataset=minist_train_dataset, shuffle=False, batch_size=batch_size)
num_examples = len(train_data)
print(num_examples)
訓練模型
這里我們使用Gluon來定義一個LeNet
# Step1 定義模型
lenet = nn.Sequential()
with lenet.name_scope():
lenet.add(nn.Conv2D(channels=20, kernel_size=5, activation='relu'))
lenet.add(nn.MaxPool2D(pool_size=2, strides=2))
lenet.add(nn.Conv2D(channels=50, kernel_size=5, activation='relu'))
lenet.add(nn.MaxPool2D(pool_size=2, strides=2))
lenet.add(nn.Flatten())
lenet.add(nn.Dense(128, activation='relu'))
lenet.add(nn.Dense(10))
# Step2 初始化模型參數
lenet.initialize(ctx=mx.gpu())
# Step3 定義loss
softmax_loss = gluon.loss.SoftmaxCrossEntropyLoss()
# Step4 優化
trainer = gluon.Trainer(lenet.collect_params(), 'sgd', {'learning_rate': 0.5})
def accuracy(output, label):
return nd.mean(output.argmax(axis=1)==label).asscalar()
def evaluate_accuracy(net, data_iter):
acc = 0
for data, label in data_iter:
data = data.transpose((0,3,1,2))
data = data.as_in_context(mx.gpu())
label = label.as_in_context(mx.gpu())
output = net(data)
acc += accuracy(output, label)
return acc / len(data_iter)
import mxnet.autograd as ag
epochs = 5
for e in range(epochs):
total_loss = 0
for data, label in train_data:
data = data.transpose((0,3,1,2))
data = data.as_in_context(mx.gpu())
label = label.as_in_context(mx.gpu())
with ag.record():
output = lenet(data)
loss = softmax_loss(output, label)
loss.backward()
trainer.step(batch_size)
total_loss += nd.mean(loss).asscalar()
print("Epoch %d, test accuracy: %f, average loss: %f" % (e, evaluate_accuracy(lenet, test_data), total_loss/num_examples))
背后的英雄 nn.Block
我們前面使用了nn.Sequential
來定義一個模型,但是沒有仔細介紹它,它其實是nn.Block
的一個簡單的形式。而nn.Block
是一個一般化的部件。整個神經網絡可以是一個nn.Block
,單個層也是一個nn.Block
。我們可以(近似)無限地嵌套nn.Block
來構建新的nn.Block
。nn.Block
主要提供3個方向的功能:
- 存儲參數
- 描述
forward
如何執行 - 自動求導
所以nn.Sequential
是一個nn.Block
的容器,它通過add
來添加nn.Block
。它自動生成forward()
函數。一個簡單實現看起來如下:
class Sequential(nn.Block):
def __init__(self, **kwargs):
super(Sequential, self).__init__(**kwargs)
def add(self, block):
self._children.append(block)
def forward(self, x):
for block in self._children:
x = block(x)
return x
知道了nn.Block
里的魔法后,我們就可以自定我們自己的nn.Block
了,來實現不同的深度學習應用可能遇到的一些新的層。
在nn.Block
中參數都是以一種Parameter
的對象,通過這個對象的data()
和grad()
來訪問對應的數據和梯度。
my_param = gluon.Parameter('my_params', shape=(3,3))
my_param.initialize()
(my_param.data(), my_param.grad())
每個nn.Block
里都有一個類型為ParameterDict
類型的成員變量params
來保存所有這個層的參數。它其際上是一個名稱到參數映射的字典。
pd = gluon.ParameterDict(prefix='custom_layer_name')
pd.get('custom_layer_param1', shape=(3,3))
pd
自義我們自己的全連接層
當我們要實現的功能在Gluon.nn模塊中找不到對應的實現時,我們可以創建自己的層,它實際也就是一個nn.Block
對象。要自定義一個nn.Block
以,只需要繼承nn.Block
,如果該層需要參數,則在初始化函數中做好對應參數的初始化(實際只是分配的形狀),然后再實現一個forward()
函數來描述計算過程。
class MyDense(nn.Block):
def __init__(self, units, in_units, **kwargs):
super(MyDense, self).__init__(**kwargs)
with self.name_scope():
self.weight = self.params.get(
'weight', shape=(in_units, units))
self.bias = self.params.get('bias', shape=(units,))
def forward(self, x):
linear = nd.dot(x, self.weight.data()) + self.bias.data()
return nd.relu(linear)
審視模型的參數
我們將從下面三個方面來詳細講解如何操作gluon定義的模型的參數。
- 初始化
- 讀取參數
- 參數的保存與加載
從上面我們們在mnist訓練一個模型的步驟中可以看出,當我們定義好模型后,第一步就是需要調用initialize()
對模型進行參數初始化。
def get_net():
net = nn.Sequential()
with net.name_scope():
net.add(nn.Dense(4, activation='relu'))
net.add(nn.Dense(2))
return net
net = get_net()
net.initialize()
我們一直使用默認的initialize
來初始化權重。實際上我們可以指定其他初始化的方法,mxnet.initializer
模塊中提供了大量的初始化權重的方法。比如非常流行的Xavier
方法。
#net.initialize(init=mx.init.Xavier())
x = nd.random.normal(shape=(3,4))
net(x)
我們可以weight
和bias
來訪問Dense的參數,它們是Parameter
對象。
w = net[0].weight
b = net[0].bias
print('weight:', w.data())
print('weight gradient', w.grad())
print('bias:', b.data())
print('bias gradient', b.grad())
我們也可以通過collect_params
來訪問Block
里面所有的參數(這個會包括所有的子Block)。它會返回一個名字到對應Parameter
的dict。既可以用正常[]
來訪問參數,也可以用get()
,它不需要填寫名字的前綴。
params = net.collect_params()
print(params)
print(params['sequential18_dense0_weight'].data())
print(params.get('dense0_bias').data()) #不需要名字的前綴
延后的初始化
如果我們仔細分析過整個網絡的初始化,我們會有發現,當我們沒有給網絡真正的輸入數據時,網絡中的很多參數是無法確認形狀的。
net = get_net()
net.collect_params()
net.initialize()
net.collect_params()
我們注意到參數中的weight
的形狀的第二維都是0, 也就是說還沒有確認。那我們可以肯定的是這些參數肯定是還沒有分配內存的。
net(x)
net.collect_params()
當我們給這個網絡一個輸入數據后,網絡中的數據參數的形狀就固定下來了。而這個時候,如果我們給這個網絡一個不同shape的輸入數據,那運行中就會出現崩潰的問題。
模型參數的保存與加載
gluon.Sequential
模塊提供了save
和load
接口來方便我們對一個網絡的參數進行保存與加載。
filename = "mynet.params"
net.save_params(filename)
net2 = get_net()
net2.load_params(filename, mx.cpu())
Hybridize
從上面我們使用gluon來訓練mnist,可以看出,我們使用的是一種命令式的編程風格。大部分的深度學習框架只在命令式與符號式間二選一。那我們能不能拿到兩種泛式全部的優點呢,事實上這一點可以做到。在MXNet的GluonAPI中,我們可以使用HybridBlock
或者HybridSequential
來構建網絡。默認他們跟Block
和Sequential
一樣是命令式的。但當我們調用.hybridize()
后,系統會轉撚成符號式來執行。
def get_net():
net = nn.HybridSequential()
with net.name_scope():
net.add(
nn.Dense(256, activation="relu"),
nn.Dense(128, activation="relu"),
nn.Dense(2)
)
net.initialize()
return net
x = nd.random.normal(shape=(1, 512))
net = get_net()
net(x)
net.hybridize()
net(x)
注意到只有繼承自HybridBlock的層才會被優化。HybridSequential和Gluon提供的層都是它的子類。如果一個層只是繼承自Block,那么我們將跳過優化。我們可以將符號化的模型的定義保存下來,在其他語言API中加載。
x = mx.sym.var('data')
y = net(x)
print(y.tojson())
可以看出,對於HybridBlock
的模塊,既可以把NDArray作為輸入,也可以把Symbol
對象作為輸入。當以Symbol
作為輸出時,它的結果就是一個Symbol
對象。