機械学習の実装でつまづいたところを共有する

# 1. はじめに

タイトル通り，機械学習の実装（[SSD（Single Shot multibox Detector）](https://github.com/jjjkkkjjj/pytorch_SSD)，[Textboxes++](https://github.com/jjjkkkjjj/pytorch_TextBoxesPP)，それらをまとめた[pytorch.dl](https://github.com/jjjkkkjjj/pytorch.dl)や[MILES](https://github.com/jjjkkkjjj/MIL)など）で私自身がつまづいたところを共有し，その対処法などを共有したいと思います．また，「章.節-A」がアンチパターン，「章.節-P」が私が使っているアンチパターンを防ぐパターンを示しています．


# 2. デバッグ
## 2.1-A Print文デバッグ
私は，上記の実装でデバッグを行うとき，当初はPrint文デバッグを行っていました．Print文デバッグとは，確認したい変数を`print()`を用いてコンソールに表示することでデバッグを行うことです．
例えば，2行5列の行列`x`と`y`で，偶数の行・列は`y`に置き換えるようなプログラムがあったとします．（本来はNumpyの`ndarray`に対してFor文は使いたくないですが，例なのでご容赦ください．[→後述](#Numpyの`ndarray`でFor文を使いすぎる)）

```python:例
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`だから．）

```:最終的なxの値
[[0 0 0 0 0]
 [0 6 0 8 0]]
```

原因がわからない場合，以下のように`print()`で値の状態を表示させて何がおかしいかを見るのがPrint文デバッグです．

```python: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)
```

```:consoleの出力
[[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](https://qiita.com/cirusthenter/items/4bae72a5bd17a53883f3#how-to-debug)」にもあるように，船（IDEなどのデバッグツール）で行けば高速に目的地にたどりつけるのに，手漕ぎ（Print文デバッグ）で目的地に向かおうとしているようなものなのですね．

機械学習のコードの場合は，大概がまあまあ大規模なコードになり，扱う変数の量も多くなるので，Print文デバッグは本当にやめたほうがいいと思います．

## 2.1-P 統合開発環境（IDE）を使う
Print文デバッグを使わないようにするためにもIDEを使うことをおすすめします．私はPycharmというIDEを使っているのですが，かなりデバッグしやすいと思っています．

上記の例を使ってPycharmでデバッグを行うと，該当行での値の情報（図中青四角「Variables」），該当行でのPrint文デバッグ（図中赤四角「Watches」）などをわかりやすく表示してくれます．
特に「Watches」では**動的に**変数の情報を表示してくれます．どういうことかというと，該当行での`x`や`y`を使って，`x==y`や`x+y`がどうなるかなどが見れるということです．これは結構便利なので，是非とも活用したいところです．

![Screenshot from 2020-06-25 21-33-27.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253935/3c7fa93b-e512-c84e-4eaa-cd3ce9d05c33.jpeg)

Pycharmについては「[最強のPython統合開発環境PyCharm](https://qiita.com/yamionp/items/f88d50da8d6b548fc44c)」で詳しく述べられているので，そちらをご覧ください．

## 2.2-A コメントを書かない
これも「[海外で通用するエンジニアがクスッとしてしまう17の meme](https://qiita.com/cirusthenter/items/4bae72a5bd17a53883f3#how-programmers-comment)」に挙げられているように，コメントを書くのは面倒なものです．仮に自分専用のコードだったとしても，「今日の自分は未来の自分にとって他人」ですので，しっかりコメントは残しておくべきでしょう．


## 2.2-P コメントを書く
私個人は，

1. 関数の引数の説明を書く
2. 変数の型を書く
3. 変数が多次元配列である場合は，各次元が何を表すかを書く（`shape`の情報も書く）

を意識して書いています．特に機械学習のコードでは，多次元配列を扱うことが普通なので，3.は意識して書いています．また，変数名は多少長くなっても構わないので，なんの変数かは変数名を見れば大方わかるようにしています．（例：`classifier_source_names`→classifierの派生元となる層の名前たち）

例：以下は，SSDの順伝搬のメソッドのコメント例です．もっといい書き方あれば教えてください．．．

```python:ssd/models/base.py
    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ファイルを作成して可視化しています．これを行い始めてから未然にバグを防げるようになった気がします．

例：[pytorch.dl/demo/ssd](https://github.com/jjjkkkjjj/pytorch.dl/tree/master/demo/ssd)

データセットの可視化：

![vizdataset.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253935/42b39eed-565d-da24-8d12-379734acd7b4.png)

SSDのデフォルトボックスの可視化：

![vizdefaultbox.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253935/466872fd-126c-fa88-b5c6-a15dde1e046d.png)


## 3.2-A スケーリングしない
[今更PytorchでSSDを実装してみた](https://qiita.com/jjjkkkjjj/items/f92bfceaa3fdbfbe893b#%E6%AD%A3%E8%A7%A3%E3%83%A9%E3%83%99%E3%83%AB%E3%81%AE%E6%AD%A3%E8%A6%8F%E5%8C%96)にも書いたのですが，<strike>正規化</strike>標準化をしないと学習が進みませんでした．
そして，注意してほしいのが**原論文には当たり前すぎてスケーリングの話は書かれていないことも多い**点です．少なくとも私が実装したSSDやTextboxes++にはこのスケーリングの話は書かれていなかったです．それくらいスケーリングは適宜行ってくださいということなんでしょうかね．


## 3.2-P スケーリングする
ということで，スケーリングをしましょう．[Feature Scalingはなぜ必要？](https://qiita.com/ttskng/items/2a33c1ca925e4501e609)より引用しますと，各手法に対して適切にスケーリングを行うと良いでしょう．

>正規化
使うとき:
- 画像処理における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次元配列の各要素の小さいものを取得するようにします．

```python:例
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文を乱用すると，以下のようになります．

```python: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`で実現できます．

```python:numpy.minimum使用
d = np.zeros_like(a)

start = time.time()
d = np.minimum(a, b)
t_numpy = time.time() - start
```

比較結果は以下のようになります．

```python:比較結果
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次元に見せています．

![ndarray.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253935/b91d33ca-ed97-2376-88a8-1d9195e8ea36.png)

同じ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](https://ja.wikipedia.org/wiki/SIMD)の話らしいです．かなり低レベルなプログラミングの話で，いつかはこういうのも理解できるようになりたいなあと思います．そして，NumpyはSIMDを扱うBlasやlapackといったライブラリを用いています．

### 余談
余談ですが，DTWを実装した際にFor文を乱用したことによるパフォーマンスの低下に悩んでいたのですが，[StackOverFlow](https://stackoverflow.com/questions/50245057/how-can-i-calculate-recurrence-formula-including-minimization-with-numpy)で聞くと一瞬で答えが帰ってきました．`(2000, 2000)`サイズのDTWで2分以上かかっていたのが，StackOverFlowの答えを用いると1秒を切りました．

DTWの漸化式：
$$
g_{i,j} = d_{i,j} + \min_{0 \le k \le 2}{g_{i-1, j-k}}
$$

```python:For文乱用
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 
```

```python:答え
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](https://qiita.com/cirusthenter/items/4bae72a5bd17a53883f3)
[最強のPython統合開発環境PyCharm](https://qiita.com/yamionp/items/f88d50da8d6b548fc44c)
[Feature Scalingはなぜ必要？](https://qiita.com/ttskng/items/2a33c1ca925e4501e609)
[How can i calculate recurrence formula including minimization with numpy?](https://stackoverflow.com/questions/50245057/how-can-i-calculate-recurrence-formula-including-minimization-with-numpy)