Tensorflow2.3のtf.kerasで再現性を確保する

# はじめに
TensorFlowでは以下の記事のようにSeedを固定することで再現性を保つ方法がありますが，TensorFlow2.3のGPUの環境ではseedの固定だけだと毎回同じ結果になりませんでした．
(Tensorflow2.1か2.0のときは確かにseedの固定だけで同じ結果が出てたと思うんですが...)

- [[TensorFlow 2.x (tf.keras)] 乱数シードを固定して再現性を向上](https://qiita.com/bee2/items/08eab7a899c9ff56eb35)
- [kerasで再現性の担保(2020/09/22現在)](https://qiita.com/RIRO/items/29e5d7ffe464c0a4a630)
- [tensorflow(tf-keras)でGPU計算の再現性を確保する方法のメモ](https://qiita.com/Rin-P/items/acacbb6bd93d88d1ca1b)
- [tensorflowで同じコードなのに結果が異なる。再現性のある機械学習がしたい。](https://qiita.com/161abcd/items/73955a64169e449b2695)

何回か交差検証を動かしてみて同じ結果にすることができた(再現性を確認できた)ので備忘録としてまとめておきます．
検証に使用したソースコードは[Github](https://github.com/temple1026/keras-reprod-test)で公開してるので，間違いや修正点があればご指摘のほどよろしくお願いします．

# 結論
はじめに結論を書きますが，以下の記事にある通り，tf.config.threadingの設定と，環境変数TF_DETERMINISTIC_OPSとTF_CUDNN_DETERMINISTICを変えれば良いようです．

- [Realizing reproducible Machine Learning - with Tensorflow](https://suneeta-mall.github.io/2019/12/22/Reproducible-ml-tensorflow.html)


なので，TensorFlow2.3では以下のように乱数を初期化する関数を作ってプログラムの初めに呼べば再現性を保てるようです．

```python

def setSeed(seed):
    os.environ['PYTHONHASHSEED'] = '0'
    
    tf.random.set_seed(seed)
    np.random.seed(seed)
    random.seed(seed)
    
    os.environ['TF_DETERMINISTIC_OPS'] = '1'
    os.environ['TF_CUDNN_DETERMINISTIC'] = '1'

    tf.config.threading.set_inter_op_parallelism_threads(1)
    tf.config.threading.set_intra_op_parallelism_threads(1)
```

[公式のドキュメント](https://docs.nvidia.com/deeplearning/frameworks/tensorflow-release-notes/rel_19.06.html)によると，並列処理を犠牲にするのでパフォーマンスは低下するみたいです．
結論だけ書いて終わりでいいかなと思いましたが本当か気になったので，交差検証を実行して再現性が保たれているか実際に確認しました．


# 検証に使用したソースコード

[Github](https://github.com/temple1026/keras-reprod-test)に置いてあるコードの要点だけまとめておきます．

- __main.py__: MNISTの訓練用の画像に対して交差検証(n=10)をして，10回分の学習の損失と精度(accuracy)を求めます．
    - ネットワークの構成は以下の通りです．widthとheightにはMNISTの画像サイズの28が入ります．

        ```python
        def loadModel(width, height):

            layer_input = Input(shape=(width, height))
            layers_hidden = Reshape(target_shape=(width, height, 1))(layer_input)
            layers_hidden = Conv2D(filters=8, kernel_size=(3, 3), strides=1, activation="relu")(layers_hidden)
            layers_hidden = MaxPooling2D(pool_size=(2,2), strides=(2,2))(layers_hidden)
            layers_hidden = Conv2D(filters=8, kernel_size=(3, 3), strides=1, activation="relu")(layers_hidden)
            layers_hidden = MaxPooling2D(pool_size=(2,2), strides=(2,2))(layers_hidden)
            layers_hidden = Flatten()(layers_hidden) 
            layers_hidden = Dense(128, activation="relu")(layers_hidden)
            layers_hidden = Dropout(0.2)(layers_hidden)
            layer_output = Dense(10, activation="softmax")(layers_hidden)
    
            return Model(inputs=layer_input, outputs=layer_output)
        ```
        - 隠れ層が全結合層(128次元)だけの短いネットワークで実験したら処理時間に大きな差が出なかったので畳み込み層を追加してます
    - 交差検証にはscikit-learnのKFoldを使用してます
        - 再現性があるかだけ見たかったので，訓練も評価も訓練用の画像だけしか使っていません

- __eval.py__: 保存したテキストファイルから分散の平均と処理時間を出力します．
    - 10回交差検証を行うので，10交差検証の各回の分散が0なら全く同じ結果=再現性が確認できたと判断します
    - main.pyがテキストファイルを生成するので，それらを読み込みます．
        - テキストには1回目の保存時間，1回目の10交差検証の結果，2回目の保存時間，2回目...と保存されているので(結果を参照)，各行を読んだ後に時間と結果を別々のリストに入れて分散と処理時間を表示してます．

    ```python
    def getResults(path_result, n_splits, num_train):
        with open(path_result, "r") as f:
            lines = f.readlines()

        results = []
        dates = []

        for line in lines:
            l = line.splitlines()[0].split()

            if len(l) != 1:
                results.append(l)
            else:
                dates.append(datetime.strptime(l[0], "%Y%m%d-%H%M%S"))
    
        results = np.array(results).astype(np.float64)
        results = np.reshape(results, newshape=(num_train, n_splits, 3))
    
        var = np.mean(np.var(results[:,:,1:], axis=0))
        diff_time_total = np.sum([dates[i + 1] - dates[i] for i in range(0, num_train-1)])

        print(f"{path_result}: {var:.4}, {diff_time_total}")
        return

    getResults(path_result=os.path.join("results", "with_tf_option.txt"), n_splits=10, num_train=10)
    ```
    - (分散については損失も精度もあわせて平均(np.mean)してしまっていますが，別々にしたほうがよかったですね)
    - __注) 結果を.txtに保存して次の結果を.txtに保存するまでの時間の合計なので，学習時間の正確な時間ではないです__
  
- ___run_all.sh (Ubuntu用) or run_all.ps1(Win用)___: main.pyを10回繰り返して，最後にeval.pyを実行するスクリプトです．
    - 再現性を保った結果はwith_tf_option.txt，再現性を保ってない結果はwithout_tf_option.txtに保存します
        - main.pyでは結果を追記していくので，シェルの実行時に既存の結果を消します

- __docker-compose.yml, env/Dockerfile__: Docker用のファイルです．(Linux向け)
    - docker-composeが使える人は```docker-compose up```だけで交差検証の各回の分散の平均と処理時間を出力します．
    - 生成したファイルがrootになることを防ぐために，現在のユーザでコンテナを実行するようにしています．なので，実行前に```export UID```をしてください 


# 検証結果

今回はWindows10のTensorFlow2.3 + Python3.7.7で実行しました．
交差検証の各回の分散の平均(Variance)と，次の結果が保存されるまでの時間の合計(Time_diff_total)は以下の通りです．

```
                                Variance, Time_diff_total
   results\with_tf_option.txt:     0.0,       0:17:11 　　　　# 1037秒
results\without_tf_option.txt:  6.694e-07,    0:13:45        # 825秒
```
今回のオプションありの場合は分散が0になってるので，10交差検証を10回実行した結果が全く同じだったと考えてよさそうです．ただ，再現性を確保しようとすると次の結果までに約104秒，再現性を捨てると次の処理までに約82秒かかってます．訓練以外に時間を食う処理は入っていないので，公式ドキュメントの通り再現性を確保するとパフォーマンスが低下すると考えてよさそうです．

ちなみにオプションありとなしの場合の1回目と2回目の損失と精度は以下の通りでした
(ありは分散が0なので2回載せる必要はありませんが念のため生の結果を載せておきます)

```
# オプションあり (回数，損失，精度)
20201002-230020
0 0.041036274284124374 0.987500011920929
1 0.0490814633667469 0.9860000014305115
2 0.05664192885160446 0.9831666946411133
3 0.05320063605904579 0.9833333492279053
4 0.04623125120997429 0.9850000143051147
5 0.047372110188007355 0.984333336353302
6 0.05214701220393181 0.9850000143051147
7 0.03892550244927406 0.9858333468437195
8 0.047721363604068756 0.9851666688919067
9 0.05081837624311447 0.984499990940094
20201002-230216
0 0.041036274284124374 0.987500011920929
1 0.0490814633667469 0.9860000014305115
2 0.05664192885160446 0.9831666946411133
3 0.05320063605904579 0.9833333492279053
4 0.04623125120997429 0.9850000143051147
5 0.047372110188007355 0.984333336353302
6 0.05214701220393181 0.9850000143051147
7 0.03892550244927406 0.9858333468437195
8 0.047721363604068756 0.9851666688919067
9 0.05081837624311447 0.984499990940094

# オプションなし
20201002-231902
0 0.039828840643167496 0.9888333082199097
1 0.049601536244153976 0.9866666793823242
2 0.05240510031580925 0.9850000143051147
3 0.05293559655547142 0.9850000143051147
4 0.04633906111121178 0.9850000143051147
5 0.04794950410723686 0.9850000143051147
6 0.053883280605077744 0.9838333129882812
7 0.03880513831973076 0.987333357334137
8 0.04899284988641739 0.9850000143051147
9 0.0499492883682251 0.9851666688919067
20201002-232034
0 0.04064466059207916 0.987666666507721
1 0.04839828982949257 0.9851666688919067
2 0.055755823850631714 0.984499990940094
3 0.05341317877173424 0.9829999804496765
4 0.046509750187397 0.9856666922569275
5 0.04652954265475273 0.9853333234786987
6 0.05223047733306885 0.984666645526886
7 0.039393164217472076 0.9869999885559082
8 0.048276182264089584 0.9854999780654907
9 0.04923468455672264 0.9848333597183228
```


#  感想
バージョンが高いTensoFlowに手を出した結果再現性が確認できず困っていたので，環境変数とオプションの設定を追加するだけで解決できてよかったです．
今回のモデルは簡単なニューラルネットワークで実験しましたが，実際の環境ではもっと層が多いネットワークを使うと思うので，今回変えた設定がパフォーマンスにどこまで影響するかが気になります．(既にある場合は教えていただければ幸いです)


# 履歴
2020/10/03 公開・書いたコードについての説明追加