os.environ["PYTHONHASHSEED"]では実験を再現できないケースがあった話

## はじめに

kaggleなどでコードの再現性を担保したいとき、以下のように[乱数シードを固定](https://qiita.com/kaggle_grandmaster-arai-san/items/d59b2fb7142ec7e270a5#seed_everything)することがあると思います。

```python
def seed_everything(seed: int):
    random.seed(seed)
    os.environ["PYTHONHASHSEED"] = str(seed)
    np.random.seed(seed)
```

ここで環境変数`PYTHONHASHSEED`が出てきますが、これは[Python内部でハッシュ値を生成する際の固定シードに使われる](https://docs.python.org/ja/3/using/cmdline.html#envvar-PYTHONHASHSEED)ようです。
以前に機械学習系のコードを書いているときに、これらのシードを設定しているはずなのに再現ができず、悩んでいたことがありました。結論としては、上の方法では`PYTHONHASHSEED`が実質的に固定できていないことが原因でした。

## 実験

まずpythonのコード側から`PYTHONHASHSEED`を変更する場合です。以下のように、`PYTHONHASHSEED`を同じ値に設定してからhash()を実行しているはずですが、hash値が一致しません。

※実行環境はWindowsです。

```bash
# 明示的にPYTHONHASHSEEDを未設定にしておく
$ set PYTHONHASHSEED=

$ python -c "import os; os.environ['PYTHONHASHSEED']='0'; print(hash('hoge'))"
7011951800243422814

$ python -c "import os; os.environ['PYTHONHASHSEED']='0'; print(hash('hoge'))"
-2235082839225683546 # 結果が一致しない
```

次に、`PYTHONHASHSEED`を設定してからpythonを実行します。すると、hash値が一致します。

```bash
# PYTHONHASHSEEDを設定
$ set PYTHONHASHSEED=42

$ python -c "import os; print(hash('hoge'))"
8970739480496197780

$ python -c "import os; print(hash('hoge'))"
8970739480496197780
```

## 上の実験で結果が一致しない原因

[KaggleのDiscussion](https://www.kaggle.com/c/google-quest-challenge/discussion/124766)によると、Pythonインタープリタは、起動時にのみPYTHONHASHSEEDを使用するようです。読み込まれた後から環境変数を上書きで変えてもダメということですね。

## 影響を受けたことがあるケース

上の現象でこれまでに悩んだケースを紹介します。

### LightGBMの列指定

LightGBMで特徴量を変えて実験をするとき、使わない特徴量`unuse_cols`を定義しておき、以下のような感じで最終的に使う列を決めるということをしたことがあります。

```python
use_cols = list(set(df.columns) - set(unuse_cols))
```

このとき、lightgbmや他のモジュールのシードは固定しているはずなのにうまく実験が再現できず悩んでいました。

結果的には、この実装では列の並び順が不定になってしまうということが原因でした。
上のコードでは、`set`は順序を保持しない集合なので、listに戻した時の並び順はハッシュ値に依存することになります。

そこで、以下のように列順を固定するようにしたところ、実験が再現できるようになりました。

```python
use_cols = sorted(list(set(df.columns) - set(unuse_cols)))
```

なお、LightGBMで列順が変わると学習結果が変わるかどうかは、データセットに依存するようです。
似たコードでも、この問題が発生するケースと発生しないケースがありました。

この問題を再現するコードを書いてみました。

```python:lightgbm_reproductivity.py
import os
import random
import warnings

import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import KFold

SEED = 42
os.environ['PYTHONHASHSEED'] = str(SEED) # ほんとは固定できていない
random.seed(SEED)
np.random.seed(SEED)

warnings.simplefilter('ignore')

if __name__ == "__main__":

    n_col = 1000
    n_data = 9999
    features = [f"col{i}" for i in range(n_col)] + ["target"]
    values = np.random.randint(0, 10, (n_data, n_col + 1))
    train_df = pd.DataFrame(dict(zip(features, values)))

    # 列順がPYTHONHASHSEEDの影響を受け、結果が変わる
    features = list(set(list(train_df.columns)) - set(["target"]))

    # 以下のようにした場合は、PYTHONHASHSEEDによらず結果は一致する
    # features = sorted(list(set(list(train_df.columns)) - set(["target"])))

    folds = KFold(n_splits=5)
    folds = list(folds.split(train_df[features], train_df["target"]))
    lgb_train = lgb.Dataset(train_df[features], train_df["target"])

    params = {
        'seed': SEED,
        'objective': 'regression',
        'metric': 'mse',
        'learning_rate': 0.05,
        'verbosity': -1,
    }

    bst = lgb.cv(
        params,
        lgb_train,
        num_boost_round=10000,
        early_stopping_rounds=50,
        return_cvbooster=True,
        folds=folds,
    )

    cvbooster = bst["cvbooster"]
    result = np.array(cvbooster.predict(train_df[features])).mean(axis=0)

    print(f"lightgbm version: {lgb.__version__}")
    print(f'MSE: {mean_squared_error(train_df["target"], result)}')
```

上のコードを実行するとき、以下のようにpython実行前に`PYTHONHASHSEED`を固定していないと、結果が一致しないことがあります。（一致するケースもあります）

```bash
$ set PYTHONHASHSEED=

$ python lightgbm_reproductivity.py
lightgbm version: 3.3.2
MSE: 2.04715139091826

$ python lightgbm_reproductivity.py
lightgbm version: 3.3.2
MSE: 2.0461430881687113
```

`set PYTHONHASHSEED=42`のように事前に固定するか、列順を`sorted()`などを使ってハッシュに依存せず一意の順番になるようにすると、結果を再現できるようになりました。

おそらく、GBDTの学習時に各ノードで使う列を決定するときに、誤差が同じ列がある場合は列順に依存して決定する、といったケースがあるのではないかと思っています。（誰か詳しい人教えてください、、、）

### PyCaretのsession_id

PyCaretでは`session_id`というパラメータでシードを固定するのですが、うまく実験が再現できないことがありました。
OS側で`PYTHONHASHSEED`を固定するとうまくいきました。

## この問題に対する対策

個人的には以下の2点を対策しています。

* なるべくhashに依存するようなコードを書かない（上のsetを使ったケースなどを避ける）
* それでも避けられない場合は、ユーザ環境変数などであらかじめ`PYTHONHASHSEED`を指定しておく

## os.environ["PYTHONHASHSEED"]でも問題ない場合

最初にあげた`seed_everything()`はよく見かけますし、[こちらの記事](https://sanshonoki.hatenablog.com/entry/2019/01/15/230054)にも記載があるように、`os.environ["PYTHONHASHSEED"]`でも問題ないケースはあると思います。
推測ですが、ライブラリ内部で`multiprocessing`などを利用している場合、`os.environ["PYTHONHASHSEED"]`で設定した環境変数がプロセス起動時に読み込まれるので、問題が発生しないのではないかと思います。

## 参考

* [Kaggleコード遺産 - seed_everything](https://qiita.com/kaggle_grandmaster-arai-san/items/d59b2fb7142ec7e270a5#seed_everything)
* [Command line and environment - Python 3.10.3 documentation - PYTHONHASHSEED](https://docs.python.org/ja/3/using/cmdline.html#envvar-PYTHONHASHSEED)
* [Reproducibility in pytorch](https://www.kaggle.com/c/google-quest-challenge/discussion/124766)
* [kerasで再現性の担保(2020/09/22現在)](https://qiita.com/RIRO/items/29e5d7ffe464c0a4a630)
* [Kerasの学習の再現性を担保する](https://sanshonoki.hatenablog.com/entry/2019/01/15/230054)