FPGAを見据えて超軽量回帰モデルのCNN(+ SVR)を実装してみる

# 0. 概要
最近CNNのFPGA搭載化が流行ってきましたね。
もうFPGA化に際しては、FINN以上のものは生まれないだろうなーってなんて思っていたところ、YoloをFPGAで動かしている動画を発見。
https://www.youtube.com/watch?v=_iMboyu8iWc

どうやら中原先生の成果らしい。論文(A Lightweight YOLOv2 A Binarized CNN with A Parallel Support Vector Regression for an FPGA)は以下。
https://www.researchgate.net/publication/323375650_A_Lightweight_YOLOv2_A_Binarized_CNN_with_A_Parallel_Support_Vector_Regression_for_an_FPGA

2分くらいで斜め読みしたところ、特徴的な点は以下であった？
<b>・Batch NormalizationはFINNの論文を参考に閾値フィルタリング
・畳み込み層はBinary-netとXNOR-netを参考に2値化（学習後に2値へ変換）
・畳み込み層から結合層の際にFully convolution layer構造を使うと非常にメモリを消費するため、
　SVRに変更</b>

学習から識別までの流れとしては以下であった。
<b> Yoloで学習→バイナリ化→結合層より前を抽出→結合層にSVRを追加して再学習→識別</b>

上2つは分かるが、一番下の結合層をSVRに変更なんてできるのか・・・？
もしこれができるのであれば、軽く論文を読む限りは、メモリの削減量が物凄い。
SVR自体のWeightは1つしかないらしい。

すなわち、今までの結合層はInput×Outputのノード分のWeightがあったが、
この手法だとOutputの個数しかないらしい。でも演算の回数は変わらないような気がする。
ようはノード1つのニューラルネットワークに近い？
仮に結合層のInputが2 * 2 * 128でOutputが5だった場合、旧来の方法では、2560の重みが必要
それに対して今回の方法だと5つだけ。これは凄い。

そこで少し実験してみた。

<b>結果: SVRに変更してもニューラルネットワークより重みの数と計算量を減らすことができなさそう。どうすればよいのか・・・。</b>



# 1. BNNのプログラム作成
Binary-netの素体がGithubに上がっていたのでこれを拝借
https://github.com/DingKe/nn_playground/tree/master/binarynet

SVRを使うので、回帰問題に変更したい。
そこで、取り合えずmnistで入力に1が入ってきたら0.1を出力、2が入ってきたら0.2を出力みたいなクソプログラムを作ってみる。試せればよいので値の範囲とか細かいことは気にしない。

`mnist_cnn.py`を開き、ラベルをone-hot-vectorから回帰に変更。
また、結合層のDenseも浮動小数点化する。もちろん損失関数も変更。

``` python
X_train = X_train.reshape(60000, 1, 28, 28)
X_test = X_test.reshape(10000, 1, 28, 28)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255

print(X_train.shape[0], 'train samples')
print(X_test.shape[0], 'test samples')

# convert class vectors to binary class matrices
Y_train = y_train / 10 
Y_test = y_test / 10 

model = Sequential()
# conv1
model.add(BinaryConv2D(256, kernel_size=kernel_size, input_shape=(channels, img_rows, img_cols),
                       data_format='channels_first',
                       H=H, kernel_lr_multiplier=kernel_lr_multiplier,
                       padding='same', use_bias=use_bias, name='conv1'))
model.add(BatchNormalization(epsilon=epsilon, momentum=momentum, axis=1, name='bn1'))
model.add(Activation(binary_tanh, name='act1'))
# conv2
model.add(BinaryConv2D(256, kernel_size=kernel_size, H=H, kernel_lr_multiplier=kernel_lr_multiplier,
                       data_format='channels_first',
                       padding='same', use_bias=use_bias, name='conv2'))
model.add(MaxPooling2D(pool_size=pool_size, name='pool2', data_format='channels_first'))
model.add(BatchNormalization(epsilon=epsilon, momentum=momentum, axis=1, name='bn2'))
model.add(Activation(binary_tanh, name='act2'))
# conv3
model.add(BinaryConv2D(512, kernel_size=kernel_size, H=H, kernel_lr_multiplier=kernel_lr_multiplier,
                       data_format='channels_first',
                       padding='same', use_bias=use_bias, name='conv3'))
model.add(BatchNormalization(epsilon=epsilon, momentum=momentum, axis=1, name='bn3'))
model.add(Activation(binary_tanh, name='act3'))
# conv4
model.add(BinaryConv2D(512, kernel_size=kernel_size, H=H, kernel_lr_multiplier=kernel_lr_multiplier,
                       data_format='channels_first',
                       padding='same', use_bias=use_bias, name='conv4'))

model.add(MaxPooling2D(pool_size=pool_size, name='pool4', data_format='channels_first'))
model.add(BatchNormalization(epsilon=epsilon, momentum=momentum, axis=1, name='bn4'))
model.add(Activation(binary_tanh, name='act4'))
model.add(Flatten(name='flt'))

# dense1
model.add(Dense(1))
model.add(BatchNormalization(epsilon=epsilon, momentum=momentum, name='bn5'))
opt = Adam(lr=lr_start)
model.compile(loss='mean_squared_error', optimizer=opt, metrics=['acc'])
model.summary()

```

これで、取り合えず5分くらい学習を行うと

```
Test dataのラベル: [0.5 0.  0.4 0.1 0.9 0.2 0.1 0.3 0.1 0.4]
Prediction: [0.47359747 0.08724903 0.45469939 0.19697031 0.81849686 0.30036611
 0.20007152 0.31680289 0.1996292  0.41415244]
```

一応小数点一桁目だけど見れば、そこそこ合ってそう。
学習時間を掛ければきっちり合うだろうが、今回はトレンドを得られたのでよしとする。

#2. 結合層をSVRに変更
次に、上記のプログラムの`Flatten`箇所の出力のみを得る。
トレーニングに使った画像6万枚でもよいが、それだとちと重いので今回は3000枚くらい。

``` python
intermediante_layer_model = Model(inputs=model.input, outputs=model.get_layer("flt").output)
x_input = intermediante_layer_model.predict(X_train[0:3000])
```

ここで、`x_input`を見ると以下のように512×7×7の出力を得られている

```
 [1, -1, -1, -1, ... 1]
```

今度はこれをSVRに学習させていく。

``` python
model.load_weights('param.hdf5')
intermediante_layer_model = Model(inputs=model.input, outputs=model.get_layer("flt").output)
x_input = intermediante_layer_model.predict(X_train[0:3000])

svr_lin = SVR(kernel='linear', C=1e3)
y_lin = svr_lin.fit(x_input, Y_train[0:3000])

x_input_test = intermediante_layer_model.predict(X_test[0:10])
print(Y_test[0:10])
print(y_lin.predict(x_input_test))
```

では、実行する。

```
Test dataのラベル: [0.7 0.2 0.1 0.  0.4 0.1 0.4 0.9 0.5 0.9]
Prediction: [0.66795861 0.14231277 0.17944743 0.18836471 0.52399224 0.19437069
 0.52518306 0.66133984 0.45813019 0.8193802 ]
```

精度はそこそこかな・・・？
一応FPGA搭載を考えて、SVRのフォワードプロパゲーションも再現してみる。

# 3. SVRのWeightを取り出して計算

`coef_`を呼び出すとSVRのWeightを取得できる。Biasは`intercept_`である。
次に、ニューラルネットワークと同じ要領でまずは入力とWeightをお互いを掛け合わせて、最後にBiasを足す。

```
get_svr_weight = y_lin.coef_[0]
get_svr_bias = y_lin.intercept_[0]
inference = np.sum(get_svr_weight * x_input_test[0]) + get_svr_bias
print(inference)
```

すると以下の結果が得られた。

```
0.6679586141390711
```

うむ、`svr.predict`で推論した結果と手動計算が合っている。
因みに、`svr.predict`の結果は以下。

```
0.66795861 
```

<b>しかし、Weightの数 (`coef_`)を見てみると7 * 7 * 512個分ある・・・。
入力値を各次元に分解して最適化したのだと思われる。あれ？それだとニューラルネットワークと重みの数が同じだ</b>

# 4. 論文を見返してみる
<b>ニューラルネットワークと重みの数が同じだ・・・</b>
ってことで、論文を見返してみる。

まずはSVRの計算式から、

![image.png](https://qiita-image-store.s3.amazonaws.com/0/95636/af2d5de0-f1d4-e21f-b4a6-97364dbbd628.png)
論文だとWeightは1個に見える、パーセプションと計算が似てるって書いてあるから
今回の計算方法で間違いはないだろう。そのほかの条件式は、SVRのジェネラルな条件式だから今は無視する。


![image.png](https://qiita-image-store.s3.amazonaws.com/0/95636/772bb42c-70c0-c8b9-c8f4-96e30402fbac.png)
Yolov2はFCL構造なので、畳み込み演算を全てのレイヤーで実施している(?)。最終層は違うような・・？

畳み込み層において、和演算からの積がボトルネックだといっている。また、重みに大きなメモリを必要にするとも。
そして、畳み込み演算と比較してSVRは計算量が少なく、FPGAに搭載しやすいよと書いてある・・・。

<b>ん？これは最終層のFully connected layerではなく、畳み込み演算をSVRに置き換えるという話・・(？)</b>
でも、Yolo v2の最終層ってFully connected layerじゃなかったっけ？!

![1_9ER4GVUtQGVA2Y0skC9OQQ.png](https://qiita-image-store.s3.amazonaws.com/0/95636/a11e7a33-fc11-460b-b354-92c063a9a8da.png)

↑ yolo v2.



![image.png](https://qiita-image-store.s3.amazonaws.com/0/95636/d02dc69e-fe28-9349-7fd8-3603e702b774.png)
しかし、論文の図を見てみると、畳み込み演算をした後に、その特徴量をSVRに入力している。
ということは、Fully connedted layerをSVRに置き換えた今の方法で合っている・・・？

といっても、SVRが各クラス分用意されていれば、そのWeightの数はニューラルネットワークと変わらないし、処理回数も計算手法がニューラルネットワークと同じなので、変わらないだろうし。

強いて言えば最適化手法が異なるので、精度には関係があるかもしれない。

うーん、いまいち分からないぞ～。

2017年の夏頃にXilinxがYoloを（その時はまだ未発売の）UltraScaleで動かしているのを米国の展示会で見た気がする。あれはどうやって実装していたんだろうか。UltraScaleなら普通に実装してもBBとClassの数を減らせば収まりそうな気もする。



# 5. 結果＆比較
一応最後に、精度だけ比較しておきます。

まずは、ただのCNN（結合層あり）。


<b> CNN (Fully connedted layer): </b>

```
Test dataのラベル: [0.7 0.2 0.1 0.  0.4 0.1 0.4 0.9 0.5 0.9]
Prediction: [[0.76504064] [0.22770569] [0.1544111 ] [0.04696366] [0.56249446] [0.16403013] [0.4048999 ] [0.764249  ] [0.58280826] [0.90762067]]
```

次に今回の実験
<b> CNN (+SVR): </b>

```
Test dataのラベル: [0.7 0.2 0.1 0.  0.4 0.1 0.4 0.9 0.5 0.9]
Prediction: [0.66795861 0.14231277 0.17944743 0.18836471 0.52399224 0.19437069
 0.52518306 0.66133984 0.45813019 0.8193802 ]
```

おー！！結構近い！
これが座標なら殆ど間違ってないといってもよい！Weightの数が減らせるなら凄い。
今後のFPGAではこの実装が一般的になる？？というか実装あってるかな？

何かお気づきの点があればご指摘お願いします。