Eclipse Deeplearning4j GitChat課程:https://gitbook.cn/gitchat/column/5bfb6741ae0e5f436e35cd9f
Eclipse Deeplearning4j 系列博客:https://blog.csdn.net/wangongxi
Eclipse Deeplearning4j Github:https://github.com/eclipse/deeplearning4j
在之前的文章中我們介紹了Deeplearning4j/ND4j中的自動微分工具SameDiff的基本使用和可視化方法,這篇文章我們介紹下如何使用SameDiff建模CNN網絡,主要涉及卷積、池化等經典的CNN OP,使用的數據集也是之前我們介紹過的Mnist手寫體數字數據集。我們嘗試使用SameDiff提供的CNN相關算子搭建類似LeNet-5結構的卷積神經網絡來對Mnist數據集進行分類。由於在之前的博客中我們並沒有詳細闡述過CNN的理論,因此我們先重新梳理下CNN的原理並且回顧下LeNet-5經典結構,在最後我們給出SameDiff建模的具體邏輯和訓練評估結果。
1. 卷積神經網絡的計算原理
卷積神經網絡(CNN)最早成功應用於機器視覺領域,包括但不僅限於圖像分類、主體檢測、語義分割等場景。目前CNN結構也廣泛用於自然語言處理領域和語音識別領域。我們這裏重點討論在圖像領域的應用。對於計算機來說,圖像信號和一個數字矩陣沒有什麼本質的不同,只不過灰度圖和RGB圖像的通道數不同使得這個數字矩陣的維度有所不同。數字矩陣本身沒什麼語義信息,但在成像之後人的肉眼具備提取其語義的能力。這裏的語義可以是輪廓、顏色、紋理等特徵,也可以是人、動物、植物這樣具備物理意義的主體內容。早期的機器視覺的任務主要集中在特徵提取的工作上,對於特徵提取後採用的是SVM還是Tree-based模型其實效果的提升不是很顯著,換言之,早期特徵的質量決定了應用的上限。我們至今熟悉的SIFT、HOG特徵依然是這些工作中的傑出成果。卷積神經網絡其實是將特徵提取這塊的工作和後續的機器學習建模結合在一起,做成了一個end-to-end的應用。開發人員不用關心CNN到底提取了什麼樣的特徵來做後續的工作,而只需要將原始信號作爲輸入喂到CNN中,輸出則是我們所關心的結果。
但作爲開發人員來說,僅僅是知道這些操作過程是不可能完成一個成功的應用的,因爲當模型整體的效果不好的時候,評估指標不理想的時候,如何調優的問題其實就是對CNN原理進一步加深瞭解的問題。雖說深度學習的解釋性很差,而且很多時候連凸優化理論都不滿足,但是這並不是開發人員不去了解或者回避其計算原理的藉口。至少,對於CNN中的OP都在做什麼,爲什麼可能會work,需要有自己的理解和經驗積累。我們回到CNN的原理上,先看下面的這張圖。
圖中一共有4列,Input Volume/ Filter W0/ Filter W1/ Output Volume。Input Volume可以認爲就是原始圖像信號,這裏Input Volume有三個數字矩陣,那就可以理解成RGB三個通道。Filter W0和Filter W1,它們可以被稱爲濾波器或者卷積核,本質上也是一個數值矩陣,只不過相對於輸入的圖像信號來說小很多。Filter矩陣中的數值可以是服從某種分佈生成的,它的主要作用是和Input Volume的數值矩陣進行所謂的卷積運算。這裏的卷積其實和深度學習本身沒什麼直接關係,它是一種存在已久的信號處理手段,詳情可見鄭君里老師的《信號與系統》一書中相關章節的描述。在CNN中,Filter和Input Volume的卷積運算,其實是Filter數值矩陣和Input Volume對應位置的子矩陣進行Hadamard乘積並加和。所謂Hadamard乘積就是兩個size完全相同的數值矩陣,對應位置做乘法計算。這個操作其實並不複雜,那麼它的作用是什麼呢?其實就是提取圖像中的特徵。用Filter提取的特徵你很難用顏色或者紋理這些帶有具體物理含義的去描述,也就是其可解釋性並不是那麼好。但是在模型訓練過程中,Filter數值矩陣中的值會不斷調整,使得Filter提取的特徵不斷得爲擬合最終的目標而服務。也就是說,特徵提取的方式是在訓練中不斷優化的,提取的特徵也是在不斷調整的,這和原來靜態的特徵提取方式相比顯然更加智能。
卷積神經網絡有三個特點:局部感知、權值共享以及池化下采樣。局部感知其實就是上述的Filter,這個size不大的數值矩陣和對應圖片中的部分區域做特徵提取的過程。由於Filter的size一般不是很大,因此其關注的區域肯定是圖片的局部,因此稱爲局部感知。權值共享指的是Filter矩陣中的數值不會隨着掃描原始圖片局部區域的不同而不同,從某種意義上說,一個固定的Filter只能從圖片中提取一類特徵,如果覺得不夠,那麼可以設置多個Filter。像截圖裏,我們可以看到就是有兩個Filter,它們的數值不盡相同,因爲提取的特徵也就不一樣了,也許一個是形狀一個是顏色。池化下采樣我們來看下下面的截圖。
截圖是最大池化(max-pooling)。我們可以看到,每個不同顏色區域的四個數值內,我們選取的數值最大的那個數作爲輸出的結果。最大池化的物理含義可以認爲是我們選擇了局部區域內最具有代表性的特徵作爲輸出。池化的另一個好處在於,輸出的數值矩陣的size顯著減小了,也就是維度降低了。除了最大池化外,另一個常用的是平均池化(average-pooling),也就是將局部區域中的數值加權平均作爲輸出。這兩種最爲常用。
卷積神經網絡中的經常提到的幾個術語,這裏也順便羅列下。
- 卷積核/濾波器(kernel/filter):通常爲22,33,5*5大小的一個數值矩陣,用於提取圖像局部特徵
- 池化(pooling):亦稱下采樣,主要用於減小特徵維度和選擇特徵的作用
- 步長(stride):卷積或者池化過程中每次移動的像素點的數量
- 特徵圖(feature map):卷積或者池化操作的輸出,一般是幾十個二維數值矩陣
- 填充(padding):在原圖像外圍填充數字(比如:0),用於多次提取圖像的邊緣特徵
2. LeNet-5結構回顧
我們先來看張圖。
Lenet-5是一個具體里程碑意義的卷積神經網絡結構,無論是後來的AlexNet還是GoogLenet、Resnet
等等都或多或少從中獲取了靈感。從截圖中可以明顯的看出卷積+池化/下采樣+卷積+池化/下采樣+全連接+全連接的結構。Lenet-5每一層的輸入輸出的維度在截圖裏都有說明,網上的很多博客都有詳細說明,這裏我就不多重複了。需要注意的是,在Lenet-5的論文《Gradient-based learning applied to document recognition》中,和現代卷積神經網絡有不同的是在於Lenet-5的激活函數還是以sigmoid爲主,現代的cnn網絡基本上都是relu系的激活函數。另外,對於多分類問題,現代網絡多用softmax,而Lenet-5的輸出是基於徑向基函數(RBF)來計算全連接輸出層的向量和標準輸出之間的歐式距離。更多信息建議去看下Yann Lecun老爺子的論文。
3. SameDiff建模Mnist數據集
這個部分我們就介紹基於SameDiff工具如何建模卷積神經網絡。我們主要是參考了Lenet-5的網絡結構,只不過諸如filter、feature map的數量和論文中會有所不同。首先我們需要創建SameDiff建模的上下文以及部分配置項
//SameDiff上下文聲明
SameDiff sd = SameDiff.create();
//Mnist數據集size聲明
int nIn = 28 * 28;
int nOut = 10;
//卷積和池化操作的配置項
Conv2DConfig layer1ConvConfig = Conv2DConfig.builder()
.kH(5) //filter height
.kW(5) //filter width
.sH(1) //stride height
.sW(1) //stride height
.pH(0) //padding height
.pW(0) //padding width
//.isSameMode(true)
.build();
Conv2DConfig layer2ConvConfig = Conv2DConfig.builder()
.kH(5)
.kW(5)
.sH(1)
.sW(1)
.pH(0)
.pW(0)
//.isSameMode(true)
.build();
Pooling2DConfig layer1PoolConfig = Pooling2DConfig.builder()
.kH(2) //filter height
.kW(2) //filter width
.sH(2) //stride height
.sW(2) //stride width
.type(Pooling2DType.MAX)//max-pooling
.build();
Pooling2DConfig layer2PoolConfig = Pooling2DConfig.builder()
.kH(2) //filter height
.kW(2) //filter width
.sH(2) //stride height
.sW(2) //stride width
.type(Pooling2DType.MAX)//max-pooling
.build();
這部分聲明主要分爲三個部分。首先我們需要聲明SameDiff的上下文環境,接着針對我們建模的數據集進行輸入和輸出size的聲明。Mnist數據集是2828的灰度圖,共0~9這10個類別,因此我們的輸入和輸出需要做個聲明。第三部分就是卷積和池化的配置項。我們聲明4個配置項,分別是CNN網絡第一層和第二層的卷積與池化操作涉及的filter、stride和padding相關的size。這裏第一層和第二層的設置其實是一樣的,之所以聲明成4個對象實例而不是2個,主要是方便開發人員後續修改它們的配置參數。我們各舉一個實例的配置進行說明,首先是layer1ConvConfig。卷積核的大小這裏我們設置成55,也就是kH和kW的大小。接着是步長stride的大小,這裏我們設置成1*1,也就是filter每次移動一個像素點的位置。padding這裏我們設置成0,也是默認設置,即不考慮外圍的padding狀況,如果需要嘗試的開發人員可以自行設置。接着說明下layer1PoolConfig的參數。池化同樣涉及filter和stride的size設置,這個和卷積操作的設置是類似的。另外,我們通過.type(Pooling2DType.MAX)開設置池化的方式,這裏我們使用的是最大池化,開發人員也可以根據自己的需要設置成諸如平均池化等其他方式。下面我們聲明下輸入和輸出的placeholder。
//Create input and label variables
SDVariable in = sd.placeHolder("input", DataType.FLOAT, -1, nIn); //Shape: [?, 784] - i.e., minibatch x 784 for MNIST
SDVariable label = sd.placeHolder("label", DataType.FLOAT, -1, nOut); //Shape: [?, 10] - i.e., minibatch x 10 for MNIST
SDVariable reshaped = in.reshape(-1, 1, 28, 28); //[numBatch, C, W, H]
這裏我們聲明瞭兩個placeholder的實例,作用是用來接收訓練數據的輸入和輸出。需要注意的是,我們採用的是mini-batch的SGD算法進行模型優化,因此在聲明placeHolder的時候我們用-1來標識未知大小,類似於TF中的None設置:tf.placeholder(tf.float32, [None, 784])。同理,標註結果的placeholder也是類似的設置。由於從Mnist二進制數據集中讀取的是784的向量,而CNN網絡對二維數據進行處理,因此我們需要重構下數據的shape,這裏和TF的OP也是一樣的,調下reshape接口,就可以拿到[numBatch, C, W, H]維度的張量數據。這裏C表示Channel,即通道數,W和H就是圖片的寬和高。下面我們看下第一層卷積層的結構聲明
// layer 1: Conv2D with a 5x5 kernel and 20 output channels
SDVariable w0 = sd.var("w0", new XavierInitScheme('c', 28 * 28 * 1, 24 * 24 * 20), DataType.FLOAT, 5, 5, 1, 20);
SDVariable b0 = sd.zero("b0", 20);
SDVariable conv1 = sd.cnn().conv2d(reshaped, w0, b0, layer1ConvConfig);
這裏聲明瞭兩個Variable對象實例,w0和b0。這是CNN需要訓練的兩個參數。w0含有55120個數值的卷積核。從其shape來看,共有20個卷積核(也意味着經過第一層卷積後會有20個feature map的輸出,輸出的數值矩陣的shape是2424*20)。XavierInitScheme是用來做參數初始化的策略。b0是偏置項,這裏我們直接用zero來把20個數值全部初始化爲0。最後我們調用conv2d這個方法來進行第一層的卷積操作,這個和TF中的conv2d的接口也是類似的。下面我們來看下第二層的張量操作,也就是最大池化。
// layer 2: MaxPooling2D with a 2x2 kernel and stride, and ReLU activation
SDVariable pool1 = sd.cnn().maxPooling2d(conv1, layer1PoolConfig);
SDVariable relu1 = sd.nn().leakyRelu(pool1, 0.5);
我們將上面卷積操作得到的張量對象conv1作爲入參輸入到maxPooling2d的方法中,同時傳入的是第一層的max-pooling的配置,這個OP得到的張量變量就是最大池化的結果。緊接着我們調用了非線性激活函數leakyRelu來提取非線性特徵。那麼到此,第一部分的卷積+池化就完成了。下面第二部分的張量操作也是類似的,我們直接給出第二部分的卷積+池化操作。
// layer 3: Conv2D with a 5x5 kernel and 50 output channels
SDVariable w1 = sd.var("w1", new XavierInitScheme('c', 12 * 12 * 20, 8 * 8 * 50), DataType.FLOAT, 5, 5, 20, 50);
SDVariable b1 = sd.zero("b1", 50);
SDVariable conv2 = sd.cnn().conv2d(relu1, w1, b1, layer2ConvConfig);
// layer 4: MaxPooling2D with a 2x2 kernel and stride, and ReLU activation
SDVariable pool2 = sd.cnn().maxPooling2d(conv2, layer2PoolConfig);
SDVariable relu2 = sd.nn().leakyRelu(pool2, 0.5);
這個和第一部分是類似的,我就不再多做解釋了。下面我們參考Lenet-5最後兩層的結構,將池化後的結果展開成全連階層。
SDVariable flat = relu2.reshape(-1, 4 * 4 * 50);
// layer 5: MLP layer
SDVariable wOut = sd.var("wOut", new XavierInitScheme('c', 4 * 4 * 50, 400), DataType.FLOAT, 4 * 4 * 50, 400);
SDVariable bOut = sd.zero("bOut", 400);
SDVariable relu3 = sd.nn().leakyRelu(flat.mmul(wOut).add(bOut), 0.5);
// layer 6: Output layer
SDVariable wFinalOut = sd.var("wFinalOut",new XavierInitScheme('c', 400, 10), DataType.FLOAT, 400, 10);
SDVariable bFinalOut = sd.zero("bFinalOut", 10);
倒數第二層的全連階層是一個800 * 400的全連階層,而最後的一層全連階層也就是輸出層是一個400 * 10的前饋網絡層。輸出的10個神經元就對應label 0~9。最後我們定義下loss函數。
SDVariable feedforwardResult = sd.nn().linear("z", relu3, wFinalOut, bFinalOut);
// softmax crossentropy loss function
SDVariable loss = sd.loss().softmaxCrossEntropy("loss", label, feedforwardResult);
sd.setLossVariables(loss);
我們將最終得到的網絡輸出和標準的label作爲入參喂到損失函數softmaxCrossEntropy中,並且在上下文中設置下這個loss變量。那麼到此,CNN的建模工作就全部完成了。我們可以通過summary接口來看下網絡涉及的哪些Variable和OP。
這張截圖裏我們可以看到在實現過程中所有涉及的placeholder、variable以及constant。
上圖是所有涉及的張量變換操作。
最後我們給出讀取數據和訓練模型的相關邏輯。
Evaluation evaluation = new Evaluation();
double learningRate = 1e-2;
TrainingConfig config = new TrainingConfig.Builder()
.l2(1e-3) //L2正則化
.updater(new Adam(learningRate)) //Adam優化器
.dataSetFeatureMapping("input") //輸入數據的變量名
.dataSetLabelMapping("label") //輸出數據變量名
.trainEvaluation("out", 0, evaluation) //評估涉及的變量名
.build();
sd.setTrainingConfig(config);
sd.addListeners(new ScoreListener(1)); //loss監聽器
int batchSize = 32;
//Mnist數據集讀取
DataSetIterator trainData = new MnistDataSetIterator(batchSize, true, 12345);
int numEpochs = 10;
History hist = sd.fit()
.train(trainData, numEpochs)
.exec();
//評估模型
List<Double> acc = hist.trainingEval(Metric.ACCURACY);
System.out.println("Accuracy: " + acc);
從控制檯打印的日誌中可以看到在模型訓練過程中loss值的下降情況,以及最後在訓練集上的評估結果。如果開發人員需要進一步提升模型的準確率,那麼可以從超參數層面以及模型結構層面進行調整,這裏不多展開。以上就是基於SameDiff進行了完整的CNN建模邏輯,可以作爲入門SameDiff使用的一個參考。
4. 小結
這篇文章我們主要介紹了基於SameDiff的類Lenet-5結構的CNN網絡建模以及結合Mnist數據集進行測試。SameDiff的建模方式在之前的文章中我們已經有過介紹,大體的方式是一致的,需要先聲明SameDiff上下文環境以及結合ND4j提供的OP構建整個CNN模型。在定義完網絡結構以及相關的損失函數以後,我們就可以準備建模數據,設置訓練的一些超參數。
SameDiff建模相比於Deeplearning4j原先提供的基於Layer提供的建模方式會比較繁瑣,甚至更關注於張量的數據變換而沒有整體的網絡結構。但是,對於從事科研工作的人員來說,自定義OP並驗證整體效果會非常方便,而對於工業界的開發來說,和Keras或者TF2.0接口更像的基於Layer的建模方式會有更高的工作效率,所以還是看各自的需要來使用。