1. はじめに
タイトル通り,機械学習の実装(SSD(Single Shot multibox Detector),Textboxes++,それらをまとめたpytorch.dlやMILESなど)で私自身がつまづいたところを共有し,その対処法などを共有したいと思います.また,「章.節-A」がアンチパターン,「章.節-P」が私が使っているアンチパターンを防ぐパターンを示しています.
2. デバッグ
2.1-A Print文デバッグ
私は,上記の実装でデバッグを行うとき,当初はPrint文デバッグを行っていました.Print文デバッグとは,確認したい変数をprint()
を用いてコンソールに表示することでデバッグを行うことです.
例えば,2行5列の行列x
とy
で,偶数の行・列はy
に置き換えるようなプログラムがあったとします.(本来はNumpyのndarray
に対してFor文は使いたくないですが,例なのでご容赦ください.→後述)
import numpy as np
x = np.arange(10).reshape((2, 5))
y = np.random.uniform(0, 1, 10).reshape((2, 5))
for i in range(x.shape[0]):
for j in range(x.shape[1]):
if i % 2 == 0 or j % 2 == 0:
x[i, j] = y[i, j]
print(x)
一見良さげですが,y
の値が代入する際に0に置き換わってしまいます.(原因は,x
のdtype
がint
,y
のdtype
がfloat
だから.)
[[0 0 0 0 0]
[0 6 0 8 0]]
原因がわからない場合,以下のようにprint()
で値の状態を表示させて何がおかしいかを見るのがPrint文デバッグです.
import numpy as np
x = np.arange(10).reshape((2, 5))
y = np.random.uniform(0, 1, 10).reshape((2, 5))
print(x)
print(y)
for i in range(x.shape[0]):
for j in range(x.shape[1]):
if i % 2 == 0 or j % 2 == 0:
print(x[i, j], y[i, j])
x[i, j] = y[i, j]
print(x[i, j], y[i, j])
print(x)
[[0 1 2 3 4]
[5 6 7 8 9]]
[[0.59673454 0.38213651 0.43256083 0.48848876 0.56126905]
[0.61864129 0.13625416 0.81237043 0.60759792 0.62323364]]
0 0.5967345373534628
0 0.5967345373534628
1 0.38213650984493375
0 0.38213650984493375
2 0.4325608266413392
0 0.4325608266413392
3 0.48848876379329054
0 0.48848876379329054
4 0.5612690522909245
0 0.5612690522909245
5 0.6186412915161512
0 0.6186412915161512
7 0.8123704265286137
0 0.8123704265286137
9 0.6232336377297621
0 0.6232336377297621
この程度のプログラムなら,このPrint文デバッグでも問題ないかもしれませんが,コードが大規模になればなるほど,
- 表示させる変数が多くなる
- 出力させる変数が多くなると,どの変数を出力させたかわかりづらくなる
- コードのどの行の変数の出力なのかわかりづらくなる
- Print文を挿入する箇所を何回も変えて,その回数分プログラムを回さなければならない
等の理由により,デバッグに時間がかかってしまいます.「海外で通用するエンジニアがクスッとしてしまう17の meme」にもあるように,船(IDEなどのデバッグツール)で行けば高速に目的地にたどりつけるのに,手漕ぎ(Print文デバッグ)で目的地に向かおうとしているようなものなのですね.
機械学習のコードの場合は,大概がまあまあ大規模なコードになり,扱う変数の量も多くなるので,Print文デバッグは本当にやめたほうがいいと思います.
2.1-P 統合開発環境(IDE)を使う
Print文デバッグを使わないようにするためにもIDEを使うことをおすすめします.私はPycharmというIDEを使っているのですが,かなりデバッグしやすいと思っています.
上記の例を使ってPycharmでデバッグを行うと,該当行での値の情報(図中青四角「Variables」),該当行でのPrint文デバッグ(図中赤四角「Watches」)などをわかりやすく表示してくれます.
特に「Watches」では動的に変数の情報を表示してくれます.どういうことかというと,該当行でのx
やy
を使って,x==y
やx+y
がどうなるかなどが見れるということです.これは結構便利なので,是非とも活用したいところです.
Pycharmについては「最強のPython統合開発環境PyCharm」で詳しく述べられているので,そちらをご覧ください.
2.2-A コメントを書かない
これも「海外で通用するエンジニアがクスッとしてしまう17の meme」に挙げられているように,コメントを書くのは面倒なものです.仮に自分専用のコードだったとしても,「今日の自分は未来の自分にとって他人」ですので,しっかりコメントは残しておくべきでしょう.
2.2-P コメントを書く
私個人は,
- 関数の引数の説明を書く
- 変数の型を書く
- 変数が多次元配列である場合は,各次元が何を表すかを書く(
shape
の情報も書く)
を意識して書いています.特に機械学習のコードでは,多次元配列を扱うことが普通なので,3.は意識して書いています.また,変数名は多少長くなっても構わないので,なんの変数かは変数名を見れば大方わかるようにしています.(例:classifier_source_names
→classifierの派生元となる層の名前たち)
例:以下は,SSDの順伝搬のメソッドのコメント例です.もっといい書き方あれば教えてください...
def forward(self, x, targets=None):
"""
:param x: Tensor, input Tensor whose shape is (batch, c, h, w)
:param targets: list of Tensor, represents ground truth. if it's None, calculate as inference mode.
:return:
if training:
pos_indicator: Bool Tensor, shape = (batch, default box num). this represents whether each default box is object or background.
predicts: localization and confidence Tensor, shape is (batch, total_dbox_num, 4+class_labels)
targets: Tensor, matched targets. shape = (batch num, dbox num, 4 + class num)
else:
predicts: localization and confidence Tensor, shape is (batch, total_dbox_num, 4+class_labels)
"""
if not self.isBuilt:
raise NotImplementedError(
"Not initialized, implement \'build_feature\', \'build_classifier\', \'build_addon\'")
if self.training and targets is None:
raise ValueError("pass \'targets\' for training mode")
elif not self.training and targets is not None:
logging.warning("forward as eval mode, but passed \'targets\'")
batch_num = x.shape[0]
# feature
sources = []
addon_i = 1
for name, layer in self.feature_layers.items():
x = layer(x)
source = x
if name in self.addon_source_names:
if name not in self.classifier_source_names:
logging.warning("No meaning addon: {}".format(name))
source = self.addon_layers['addon_{}'.format(addon_i)](source)
addon_i += 1
# get features by feature map convolution
if name in self.classifier_source_names:
sources += [source]
# classifier
locs, confs = [], []
for source, loc_name, conf_name in zip(sources, self.localization_layers, self.confidence_layers):
locs += [self.localization_layers[loc_name](source)]
confs += [self.confidence_layers[conf_name](source)]
predicts = self.predictor(locs, confs)
if self.training:
pos_indicator, targets = self.encoder(targets, self.dboxes, batch_num)
return pos_indicator, predicts, targets
else:
predicts = self.decoder(predicts, self.dboxes)
return predicts
3. データセット
3.1-A 可視化せずに学習に回す
データセットの可視化もやったほうが良いと思います.
理由は2つあります.
- 入力データのバグを防ぐ
1つ目は「入力データのバグを防ぐ」です.意外にもやりがちなのが,学習に渡すそもそもの入力データが間違っているというのが往々にしてあります.(あくまで私の経験談ですが...)特に,前処理やデータ拡張などを挟むとバグが起きやすくなるので,
- 画像が正しいものなのか
- 前処理が適切に施されているか
- ラベルが画像に対して適切に対応しているか
- ラベルを適切に数値化(Onehot化等)できているか
...と,色々注意する点はありますが,しっかり可視化して確認すると良いでしょう.
- 学習の指針を立てやすくする
2つ目は「学習の指針を立てやすくする」です.原論文の再現実験などでしたらあまり必要ないかもしれませんが,自前のデータセットを学習させる際や新たなモデルを考える際に,可視化を行うと
- 入力データがどういう分布をしていてるか把握する
- どういう問題に落とし込めば良いか指針を立てやすくなる
- 問題解決の原因を把握する
ことが期待され,学習の指針を立てやすくなると思います.
3.1-P 対話型(Jupyter)を使う
対話型(Jupyter等)を使うと良さそうです.私の場合は,.py
とは別に可視化専用にjupyterファイルを作成して可視化しています.これを行い始めてから未然にバグを防げるようになった気がします.
データセットの可視化:
SSDのデフォルトボックスの可視化:
3.2-A スケーリングしない
今更PytorchでSSDを実装してみたにも書いたのですが,正規化標準化をしないと学習が進みませんでした.
そして,注意してほしいのが原論文には当たり前すぎてスケーリングの話は書かれていないことも多い点です.少なくとも私が実装したSSDやTextboxes++にはこのスケーリングの話は書かれていなかったです.それくらいスケーリングは適宜行ってくださいということなんでしょうかね.
3.2-P スケーリングする
ということで,スケーリングをしましょう.Feature Scalingはなぜ必要?より引用しますと,各手法に対して適切にスケーリングを行うと良いでしょう.
正規化
使うとき:
- 画像処理におけるRGBの強さ[0,255]
- sigmoid, tanhなどの活性化関数を用いる、NNのいくつかのモデル
標準化
使うとき:
- ロジスティック回帰、SVM、NNなど勾配法を用いたモデル
- kNN, k-meansなどの距離を用いるモデル
- PCA, LDA(潜在的ディリクレ配分法), kernel PCA などのfeature extractionの手法
使わないとき:
決定木、ランダムフォレスト
4. データ構造
4.1-A N次元配列でFor文を使いすぎる
機械学習の実装では,Numpy
のndarray
のようなN次元配列を用いることが一般的だと思います.そして,場合によってはかなり大きいサイズのN次元配列を扱うことになると思います.そして,For文を使う場面はそれなりに出てくると思います.そこで注意したいのが,For文の乱用です.For文を乱用すると著しくパフォーマンスが落ちます.
特にC言語からPythonを初めた人や初心者の人は注意したいところです.
4.1-P N次元配列用の関数を用いる
For文を乱用せずにできるだけN次元配列用の関数を用いるようにしたほうが良いです.つまりNumpy
であればnumpy.hoge
のような関数を用いるということです.
具体例
簡単な例として,以下のa,b
の2つのN次元配列の各要素の小さいものを取得するようにします.
import numpy as np
a = np.random.randint(0, 255, 3000000).reshape(1000, 1000, 3)
b = np.random.randint(0, 255, 3000000).reshape(1000, 1000, 3)
愚直にFor文を乱用すると,以下のようになります.
import time
c = np.zeros_like(a)
start = time.time()
for i in range(a.shape[0]):
for j in range(a.shape[1]):
for k in range(a.shape[2]):
c[i, j, k] = min(a[i, j, k], b[i, j, k])
t_for = time.time() - start
一方で,これと同等のことがnumpy.minimum
で実現できます.
d = np.zeros_like(a)
start = time.time()
d = np.minimum(a, b)
t_numpy = time.time() - start
比較結果は以下のようになります.
print('equal?: {}, for-loop: {}, numpy: {}'.format(np.array_equal(c, d), t_for, t_numpy))
# equal?: True, for-loop: 1.9174954891204834, numpy: 0.005002260208129883
なんと約382倍もの差がつきました!なので,For文はできるだけ使わないようにしましょう!
ちなみに私の経験談ですが,二重以上のFor文は大概何かしらの関数で代用でき,パフォーマンスアップできるようになると思います.パフォーマンスで悩まされる場合に,For文を乱用している場合は一度見直されることをおすすめします.
理由
なぜこのようなパフォーマンスの差が生まれるかを簡潔に説明してみます.(間違っていたらツッコミお願いいます...)
NumpyなどのN次元配列は一般的に,以下のようにshape
とstride
のプロパティを保持することで,メモリ上に配置された1次元の配列をN次元に見せています.
同じN次元配列を表していても,中身のshape
とstride
が違うことがわかります.stride=(3,1)
のように行から順番にメモリ上に配置している状態をRow Major Orderといい,stride=(1, 2)
のように列から順番にメモリ上に配置している状態をColumn Major Orderといいます.Numpy
のようなN次元配列を扱うライブラリは,Row MajorやColumn Majorとまではいかないにしても順番に並んだ部分を探し,一気に並列で計算します.なので,For文で1つ1つ計算するより,メモリ上で順番に並んでいる部分を一気に計算する関数を用いたほうが早いというわけです.
ちなみに,並列に一気に計算するのはSIMDの話らしいです.かなり低レベルなプログラミングの話で,いつかはこういうのも理解できるようになりたいなあと思います.そして,NumpyはSIMDを扱うBlasやlapackといったライブラリを用いています.
余談
余談ですが,DTWを実装した際にFor文を乱用したことによるパフォーマンスの低下に悩んでいたのですが,StackOverFlowで聞くと一瞬で答えが帰ってきました.(2000, 2000)
サイズのDTWで2分以上かかっていたのが,StackOverFlowの答えを用いると1秒を切りました.
DTWの漸化式:
$$
g_{i,j} = d_{i,j} + \min_{0 \le k \le 2}{g_{i-1, j-k}}
$$
indexes = [[-1,0],[-1,-1],[-1,-2]] # for genelize
for i in range(1, g_rows):
for j in range(g_cols):
tmp = np.zeros(len(indexes))
for k in range(len(indexes)):
i_ = i + indexes[k][0]
j_ = j + indexes[k][1]
if i_ >= 0 and i_ < g_rows and j_ >= 0 and j_ < g_cols:
tmp[k] = d[i,j] + g[i_,j_]
else:
tmp[k] = np.nan
g[i,j] = np.nanmin(tmp)
for ~ # back tracking for DTW
g = np.zeros(d.shape)
g[0, :] = d[0, :]
for i in range(1, d.shape[0]):
g[i, 0] = d[i, 0] + g[i-1, 0] # Edge condition for column 0
g[i, 1] = d[i, 1] + np.minimum(g[i-1, 0], g[i-1, 1]) # Edge condition for column 1
g[i, 2:] = d[i, 2:] + np.minimum.reduce([g[i-1,2:], g[i-1,1:-1], g[i-1,:-2]]) # Do remaining columns in single operation
おわりに
章.節-Pで私なりの対処法を紹介しましたが,何か別の良い方法などあれば共有いただけると幸いです.
参考
海外で通用するエンジニアがクスッとしてしまう17の meme
最強のPython統合開発環境PyCharm
Feature Scalingはなぜ必要?
How can i calculate recurrence formula including minimization with numpy?