本文介紹瞭如何使用Keras框架,搭建一個小型的神經網絡-多層感知器,並通過給定數據進行計算訓練,最後將訓練得到的模型提取出參數,在51單片機上進行部署運行。
目錄
0 - 楔子
在前一篇文章(Keras #0 - 搭建Keras環境,跑一個例程),介紹瞭如何使用 Anaconda Navigator 進行方便快捷的 Keras 安裝與部署,並且運行了一個例程來進行驗證。
當逐漸熟悉Keras框架後,便想來自己設計一個神經網絡,不需要太複雜,主要目的是爲了讓自己熟悉一下流程。瞭解一下製作一個神經網絡需要準備一些相關的數據集,設計神經網絡的結構,設計每一層的激活函數,設計最後進行模型訓練時所需要用到的損失函數(loss)以及優化器(optimizer)等等。當然,如果能將訓練好的模型運用到實際的部署環境中,而不是單純的在PC上使用python進行運行,那便更好,於是便產生了這一篇的設計。
爲了熟悉過程以及方便之後在單片機上嘗試部署,我們這裏的模型應當儘量簡單,並且邏輯不復雜,方便我們去尋找或者直接產生訓練所需要的數據集。
1 - 訓練模型
1-1 模型的設計
我們設計一個網絡,讓它能夠進行如下的簡單運算:
這裏,輸入向量爲一維,只含有三個元素 ,而輸出則直接是該計算式的結果。例如當輸入是 時,輸出結果應當爲 0.1 。
從分析的角度來看,這裏的網絡設計,只需要一層輸入層,一層隱藏層還有一層輸出層即可。其中隱藏層有3個神經元,輸出層有一個神經元,使用 NN-SVG 繪製一下示意圖:
是一個非常簡單的神經網絡模型,這種也被稱之爲 “多層感知機(MLP,Multi Layer Perceptron)”
那麼,我們也可以根據設計,在 Keras中編寫好該網絡模型。由於輸出是線性的,而不是進行分類操作,因此這裏層與層之間的激活函數使用的是線性函數:
# 構建網絡
model = Sequential()
model.add(Dense(3, activation='linear', input_shape=(3,)))
model.add(Dense(1, activation='linear'))
1-2 數據集
爲了訓練我們的模型,數據是必不可少的。由於我們的神經網絡是爲了對一個特定計算式進行擬合,因此相應的訓練與測試數據我們可以在程序中使用代碼直接生成:
gTrainDataSize = 2000
gTestDataSize = 50
x_train = np.random.uniform(0, 1, size=(gTrainDataSize, 3))
y_train = np.zeros(shape=gTrainDataSize)
x_test = np.random.uniform(0, 1, size=(gTestDataSize, 3))
y_test = np.zeros(shape=gTestDataSize)
def GetTrainData():
np.random.seed(7)
print("正在產生訓練數據")
for _index in range(x_train.shape[0]):
y_train[_index] = (x_train[_index, 0] * 0.1
+ x_train[_index, 1] * 0.2
+ x_train[_index, 2] * 0.3) / 2
# print("x_train:", x_train[_index, :])
print("x_train:", x_train)
print("y_train:", y_train)
print("訓練數據產生完畢")
print("正在產生測試數據")
for _index in range(x_test.shape[0]):
y_test[_index] = (x_test[_index, 0] * 0.1
+ x_test[_index, 1] * 0.2
+ x_test[_index, 2] * 0.3) / 2
print("x_test:", x_test)
print("y_test:", y_test)
print("測試數據產生完畢")
通過修改 gTrainDataSize 與 gTestDataSize 來修改訓練數據與測試數據的數量。 x_train 與 y_train 中分別存儲的是訓練輸入與標準輸出,用於進行訓練。而 x_test 與 y_test 則是測試時所需的數據對。
在Keras的模型訓練過程中,測試數據並不參與模型的訓練,僅僅在模型訓練的一個批次(BatchSize)訓練完成後,丟入模型,比較輸出的差(loss)與準確度(acc)來展示給用戶方便評估。
1-3 模型的訓練
接下來,可以開始模型的訓練了,這裏我將全代碼貼出,方便查閱:
from __future__ import print_function
import keras
from keras.models import Sequential
from keras.layers import Dense
from keras.optimizers import SGD, RMSprop
from keras.utils import plot_model
import numpy as np
import matplotlib.pyplot as plt
# 超參數
gTrainDataSize = 2000
gTestDataSize = 50
gBatchSize = 20 # 整個訓練數據分爲多少批次進行訓練
gEpochs = 10 # 整個訓練數據將被重複訓練的次數
'''
gSGD_lr = 0.01 # 這裏不使用SGD作爲優化器,使用默認參數的RMSprop來進行優化
gSGD_decay = 1e-6
gSGD_momentum = 0.01
'''
x_train = np.random.uniform(0, 1, size=(gTrainDataSize, 3))
y_train = np.zeros(shape=gTrainDataSize)
x_test = np.random.uniform(0, 1, size=(gTestDataSize, 3))
y_test = np.zeros(shape=gTestDataSize)
def GetTrainData():
np.random.seed(7)
print("正在產生訓練數據")
for _index in range(x_train.shape[0]):
y_train[_index] = (x_train[_index, 0] * 0.1
+ x_train[_index, 1] * 0.2
+ x_train[_index, 2] * 0.3) / 2
# print("x_train:", x_train[_index, :])
print("x_train:", x_train)
print("y_train:", y_train)
print("訓練數據產生完畢")
print("正在產生測試數據")
for _index in range(x_test.shape[0]):
y_test[_index] = (x_test[_index, 0] * 0.1
+ x_test[_index, 1] * 0.2
+ x_test[_index, 2] * 0.3) / 2
print("x_test:", x_test)
print("y_test:", y_test)
print("測試數據產生完畢")
def TrainModel():
# 獲取訓練與測試數據
GetTrainData()
# 構建網絡
model = Sequential()
model.add(Dense(3, activation='linear', input_shape=(3,)))
model.add(Dense(1, activation='linear'))
# 輸出各層參數情況
model.summary()
# 編譯
'''
sgd = SGD(lr=gSGD_lr, decay=gSGD_decay, momentum=gSGD_momentum, nesterov=True)
model.compile(loss='mean_absolute_error', optimizer=sgd, metrics=['accuracy'])
'''
model.compile(loss='mean_absolute_error', optimizer=RMSprop(), metrics=['accuracy'])
# 訓練
history = model.fit(x_train, y_train,
batch_size=gBatchSize,
epochs=gEpochs,
verbose=1,
validation_data=(x_test, y_test))
# 樣本測試
ex_test = [0.2, 0.3, 0.4]
ex_val = (ex_test[0] * 0.1 + ex_test[1] * 0.2 + ex_test[2] * 0.3) / 2
exResult = model.predict(np.array([ex_test]))
print("-------\n模型輸出結果爲:", exResult)
print("計算結果爲:", ex_val)
# 輸出訓練好的網絡模型可視化
plot_model(model, to_file='myCalculatorModel.png')
# 訓練歷史可視化 繪製訓練 & 驗證的準確率值
'''
acc會一直是0,因爲acc的計算是看模型輸出結果與標準輸出是否匹配,
而這裏在計算過程中,很難’恰好‘碰到模型輸出結果與標準輸出匹配的時候,
故acc一般都一直是0,這裏沒有必要顯示
'''
# plt.plot(history.history['acc'])
# plt.plot(history.history['val_acc'])
# plt.title('Model accuracy')
# plt.ylabel('Accuracy')
# plt.xlabel('Epoch')
# plt.legend(['Train', 'Test'], loc='upper left')
# plt.show()
# 繪製訓練 & 驗證的損失值
plt.plot(history.history['loss'])
plt.plot(history.history['val_loss'])
plt.title('Model loss')
plt.ylabel('Loss')
plt.xlabel('Epoch')
plt.legend(['Train', 'Test'], loc='upper left')
plt.show()
# 保存模型
print("Saving model to disk \n")
model.save("MyCalculatorModel.h5")
def LoadModelAndTest():
model = keras.models.load_model("./MyCalculatorModel.h5")
# 樣本測試
ex_test = [0.2, 0.3, 0.4]
ex_val = (ex_test[0] * 0.1 + ex_test[1] * 0.2 + ex_test[2] * 0.3) / 2
exResult = model.predict(np.array([ex_test]))
print("-------\n模型輸出結果爲:", exResult)
print("計算結果爲:", ex_val)
if __name__ == '__main__':
LoadModelAndTest()
# TrainModel()
這裏模型使用的損失函數是平均絕對誤差(mean_absolute_error),優化器(optimizer)則使用了Keras提供了默認參數的RMSprop,代碼中也註釋瞭如何使用隨機梯度下降(Stochastic Gradient Descent)來作爲優化器的代碼。
在模型訓練的時候,需要提供幾個參數:批(Batch),輪次(Epoch)。這兩個參數的意義可以參考 Keras 官方文檔中的介紹("sample", "batch", "epoch" 分別是什麼?)
爲了正確地使用 Keras,以下是必須瞭解和理解的一些常見定義:
- Sample: 樣本,數據集中的一個元素,一條數據。
- 例1: 在卷積神經網絡中,一張圖像是一個樣本。
- 例2: 在語音識別模型中,一段音頻是一個樣本。
- Batch: 批,含有 N 個樣本的集合。每一個 batch 的樣本都是獨立並行處理的。在訓練時,一個 batch 的結果只會用來更新一次模型。
- 一個 batch 的樣本通常比單個輸入更接近於總體輸入數據的分佈,batch 越大就越近似。然而,每個 batch 將花費更長的時間來處理,並且仍然只更新模型一次。在推理(評估/預測)時,建議條件允許的情況下選擇一個儘可能大的 batch,(因爲較大的 batch 通常評估/預測的速度會更快)。
- Epoch: 輪次,通常被定義爲 「在整個數據集上的一輪迭代」,用於訓練的不同的階段,這有利於記錄和定期評估。
- 當在 Keras 模型的
fit
方法中使用validation_data
或validation_split
時,評估將在每個 epoch 結束時運行。- 在 Keras 中,可以添加專門的用於在 epoch 結束時運行的 callbacks 回調。例如學習率變化和模型檢查點(保存)。
在訓練完畢,進行樣本測試後,我們將模型的訓練歷史可視化,觀察它的損失值在訓練的過程中的變化趨勢。
這裏我們代碼註釋掉了之前所寫的訓練過程中準確率值的歷史曲線展示,在模型訓練的過程中你也會發現,在pycharm的輸出窗口中,模型信息的 acc-即準確率值一直爲0。這是有原因的,因爲準確率是看模型的輸出值是否恰好等於給定的訓練數據的標準輸出值,若相等則計數,最後進行統計得出當前批次的訓練中的準確率。然而我們的模型在訓練過程中,直至訓練完畢,也不能100%的擬合好期望的輸出,最小的損失值目前停留在了0.2%,這意味着在訓練過程中,很難碰到剛好我們模型的輸出值等於標準輸出值,其總是有小數位上略微的差別。因此便無法記錄準確率,即使展示歷史準確率也只有一條直線而已。
運行,我們便可看到模型訓練的結果:
1-4 模型的保存與再載入
在模型訓練完畢後,我們將模型進行保存
# 保存模型
print("Saving model to disk \n")
model.save("MyCalculatorModel.h5")
這樣模型在本地便可保存成爲一個 hdf5 格式的文件,其中包含了它的參數信息,這是我們之後所需要的。
而當我們在使用Keras框架,需要再載入之前的模型時,通過調用Keras的函數即可完成模型的載入:
def LoadModelAndTest():
model = keras.models.load_model("./MyCalculatorModel.h5")
# 樣本測試
ex_test = [0.2, 0.3, 0.4]
ex_val = (ex_test[0] * 0.1 + ex_test[1] * 0.2 + ex_test[2] * 0.3) / 2
exResult = model.predict(np.array([ex_test]))
print("-------\n模型輸出結果爲:", exResult)
print("計算結果爲:", ex_val)
2 - 部署模型
2-1 模型參數的提取
前面我們已經將訓練好的模型在本地保存爲HDF5格式的文件,爲了在單片機上運行,我們首先需要將模型中各個層的權重參數以及偏置參數提取出來。這裏推薦使用 HDFView 軟件進行查看,可以在這裏(https://support.hdfgroup.org/ftp/HDF5/hdf-java/current/bin/)進行下載。
可以看到我們模型中的兩層網絡的參數,接下來我們研究如何在矩陣運算中使用這些參數。
2-2 矩陣的運算方式
這裏的參數,權重與偏置都以矩陣方式進行了保存。在我們的神經網絡中,是最簡單的多層感知器神經網絡,是順序網絡,沒有卷積及其他操作,因此我們可以用簡單的矩陣運算方法進行運算。
權重矩陣中,每一列爲一個下層神經元連接上層神經元的權重值,每一行爲一個上層神經元連接下層神經元的權重;而偏置矩陣則是一個一維向量。因此,當輸入向量定義爲行向量時,將權重矩陣右乘輸入行向量,再將得到的行向量加上偏置矩陣的轉置即可。
我們可以看一下用python實現的計算:
import numpy as np
w0 = np.array([[0.4455272, 0.55392843, -0.13121112],
[0.046779986, 0.396654, -0.022363363],
[-0.7579524, 0.4773703, -0.22135651]])
b0 = np.array([0.2265971, -0.025297487, 0.027563533])
w1 = np.array([[-0.13311525], [0.29222623], [0.4000595]])
b1 = np.array([0.02701721])
x = np.array([0.2, 0.3, 0.4])
a0 = x.dot(w0) + b0
print("a0:\n", a0)
y = a0.dot(w1) + b1
print("y:\n", y)
在代碼中,我們手動的將偏置矩陣轉換成了一個行向量,這樣在後續的代碼中直接進行矩陣加法即可。
2-3 NNLayer
NNLayer是我編寫的一個用於方便計算這類全連接網絡的,使用純C語言實現的,沒有動態內存分配的計算框架。十分適合部署在MCU這類不能夠使用動態內存分配,資源緊湊的平臺上使用。
目前NNLayer只包含了前向傳播的計算,暫時不準備將訓練部分融入(用PC進行訓練不更方便快捷嗎?)以節省代碼體積。由於目前只設計涵蓋了正對全連接網絡的計算,因此它的使用比較簡單,只需要調用兩個函數,一個進行初始化,一個調用進行預測計算即可:
NNLayer.h
#ifndef _NNLAYER_H_
#define _NNLAYER_H_
#define LAYER_TOTAL 2
#define LAYER_0_INDIM 3
#define LAYER_0_OUTDIM 3
#define LAYER_1_INDIM 3
#define LAYER_1_OUTDIM 1
#include "../common.h"
/*
* NNLayer變量定義
*/
typedef struct
{
const double* weight; //權重二維矩陣 INDIM * OUTDIM
const double* bais; //偏置數組 1 * OUTDIM
int inDim; //輸入維數
int outDim; //輸出維數
double* outVal; //輸出數組
}NNLayer;
void NNLayerInit();
void NNLayerPredict(NNLayer* model,double* x);
#endif
NNLayer.c
#include "NNLayer.h"
extern xdata NNLayer gMyCalculatorModel[LAYER_TOTAL];
extern xdata double gLayer0OutVal[LAYER_0_OUTDIM],gLayer1OutVal[LAYER_1_OUTDIM];
/*
* 訓練好的模型參數
*/
const double code W0[LAYER_0_INDIM][LAYER_0_OUTDIM] = {
// nn00 nn01 nn02
{0.4455272 ,0.55392843 ,-0.13121112},
{0.046779986,0.396654 ,-0.022363363},
{-0.7579524 ,0.4773703 ,-0.22135651}
};
const double code B0 [LAYER_0_OUTDIM] = {0.2265971 ,-0.025297487 ,0.027563533};
const double code W1[LAYER_1_INDIM][LAYER_1_OUTDIM] = {
{-0.13311525},
{0.29222623},
{0.4000595}
};
const double code B1[LAYER_1_OUTDIM] = {0.02701721};
/*
* return arr[r][c]
*/
double _getWeight(double* arr,int r,int rNum,int c)
{
return *(arr + r*rNum + c);
}
/*
* NNLayer初始化
* 綁定每層的權重矩陣、偏置矩陣
* 設置每層的輸入維度,輸出維度
* 綁定每層的輸出暫存數組
*/
void NNLayerInit()
{
gMyCalculatorModel[0].weight = W0;
gMyCalculatorModel[0].bais = B0;
gMyCalculatorModel[0].inDim = LAYER_0_INDIM;
gMyCalculatorModel[0].outDim = LAYER_0_OUTDIM;
gMyCalculatorModel[0].outVal = gLayer0OutVal;
gMyCalculatorModel[1].weight = W1;
gMyCalculatorModel[1].bais =B1;
gMyCalculatorModel[1].inDim = LAYER_1_INDIM;
gMyCalculatorModel[1].outDim = LAYER_1_OUTDIM;
gMyCalculatorModel[1].outVal = gLayer1OutVal;
}
/*
* NNLayer進行預測
* model - NNLayer數組
* x - 輸入向量,數目符合LAYER_0_INDIM
*/
void NNLayerPredict(NNLayer* model,const double* x)
{
int _indexW,_indexX,_indexLayer;
for(_indexLayer = 0 ; _indexLayer < LAYER_TOTAL ; _indexLayer++)
{
for(_indexW = 0 ; _indexW < model[_indexLayer].outDim ; _indexW++)
{
for(_indexX = 0 ; _indexX < model[_indexLayer].inDim ; _indexX++)
{
if(_indexLayer == 0)
{
model[_indexLayer].outVal[_indexW] += x[_indexX] * _getWeight(model[_indexLayer].weight,_indexX,model[_indexLayer].outDim,_indexW);
}
else
{
model[_indexLayer].outVal[_indexW] += model[_indexLayer - 1].outVal[_indexX] * _getWeight(model[_indexLayer].weight,_indexX,model[_indexLayer].outDim,_indexW);
}
}
model[_indexLayer].outVal[_indexW] += model[_indexLayer].bais[_indexW];
}
}
}
使用我們剛剛進行訓練的模型,將參數導入進行運算:
可以看見很好的復現了我們使用模型進行預測的結果
2-4 在單片機上運行
由於手頭只有一塊其他項目上測試用到的單片機板,我們只能在這塊板子上進行測試。這塊板子使用的主控是 STC15F2K60S2,主頻設置在20MHZ,板載一塊0.96寸12864oled屏幕,可通過電腦USB進行串口通訊。
參照我之前的這篇文章(關於keil-C51中code、idata以及xdata),將較大的字庫、以及神經網絡模型的參數矩陣之類不需要修改的數組,標記爲code字段,保存在代碼段中。
編寫單片機程序,主要初始化了oled屏幕、串口,並通過NNLayer進行神經網絡的初始化與計算:
int main()
{
xdata double inputVal[LAYER_0_INDIM] = {0.2,0.3,0.4};
char str[16];
int i;
//初始化外設
ScreenInit();
UartInit();
UartSend("HelloWorld\r\n");
//NNLayer
NNLayerInit();
NNLayerPredict(gMyCalculatorModel,inputVal);
//結論輸出
UartSend("The input is:");
for(i = 0;i < LAYER_0_INDIM;i++)
{
sprintf(str,"%f ",inputVal[i]);
UartSend(str);
}
UartSend("\r\ngMyCalculatorModel[0].outVal: ");
for(i = 0; i < LAYER_0_OUTDIM ; i++)
{
sprintf(str,"%f ",gMyCalculatorModel[0].outVal[i]);
UartSend(str);
}
UartSend("\r\ngMyCalculatorModel[1].outVal: ");
for(i = 0; i < LAYER_1_OUTDIM ; i++)
{
sprintf(str,"%f ",gMyCalculatorModel[1].outVal[i]);
UartSend(str);
}
ScreenFill(SCREEN_BLACK);
ScreenShowASCIIString(0,0,SCREEN_BLACK,"Input Vector:"); //輸入
sprintf(str,"%.1f %.1f",inputVal[0],inputVal[1]);
ScreenShowASCIIString(0,2,SCREEN_WHITE,str);sprintf(str,"%.1f",inputVal[2]);ScreenShowASCIIString(64,2,SCREEN_WHITE,str); //scanf的bug,一次只能兩
ScreenShowASCIIString(0,4,SCREEN_BLACK,"Predict:"); //輸出
sprintf(str,"%f",gMyCalculatorModel[1].outVal[0]);
ScreenShowASCIIString(0,6,SCREEN_WHITE,str);
while(1);
}
使用keil4進行編譯,燒錄到單片機上,我們可以看到單片機的oled屏幕上輸出的計算結果:
同時,打開串口調試助手,我們可以看到單片機通過串口發送回的計算結果:
至此,我們完成了神經網絡的搭建、訓練、參數提取以及移植到51單片機上的步驟。