Kaggle参戦記：オレオレMLOps環境を整備

相変わらず「[LLM - Detect AI Generated Text](https://qiita.com/drafts/8668827513f226920310/edit)」という、「与えられた文章を書いたのが生成AIなのか人間なのか」を当てるコンペに参加しています。残念なことに、この3連休ではビギナーズラック以降、スコアに進展はなかったのですが、参加にあたって参考にさせていただいた[元のソースコード](https://www.kaggle.com/code/erikfolkesson/detailed-explanation-custom-tokenizer)にいろいろと手を加えて、オレオレMLPOps環境っぽく整備できたので、その話をシェアします。

# トライ＆エラーのサイクルを高速化する
[元のソースコード](https://www.kaggle.com/code/erikfolkesson/detailed-explanation-custom-tokenizer)では、KaggleのNotebook上で実行する際には、ダミーのtestデータに対して何も処理せず、submitされてrunしている環境でのみ、testデータに対する判別を行うようになっていました。Tinanicの時であれば、アイデア出し⇒実装⇒評価⇒考察のサイクルをローカルの環境で素早くまわして、良さそうな評価結果だったら、Kaggleに投稿してLeadersboardのスコアを得る、といった流れで精度改善をすすめていました。データ量もさほどではないので、手元で10秒か20秒ほどすれば結果は出るし、Kaggleに投稿しても、ブラウザの更新ボタンを押せば、すぐにでも結果を確認することができていました。

```mermaid
graph LR
  A[考察]-->B[アイデア出し]
  B-->C[実装]
  C-->D[評価]
  D-->A
  D-->E[投稿]
  E-->F[LBスコア]
  F-->A
```

しかし、元のソースコードでは、「手元でトライ＆エラーのサイクルを回す」ようにはできておらず、実装後、手元で評価検証することなく投稿。ぶっつけ本番でLBスコアを算出するようになっていました。投稿～LBスコア算出までに2～3時間くらいかかる上に、1日の投稿回数が5回までなので、これでは到底精度向上はのぞめないということで、手元で実装⇒評価⇒考察のサイクルが回せるように改良しました。

```mermaid
graph LR
  A[考察]-->B[アイデア出し]
  B-->C[実装]
  C-->E[投稿]
  E-->F[LBスコア]
  F-->A
```

そのために手を加えた部分は主に2つです。
①手元のローカル環境でのRunか、投稿後の本番環境でのRunかを判別して、ローカル環境だったら、学習データとテストデータを分割する
~~~.py
if param["run"]["LOCAL_RUN"]:
    # ローカルでのRunならデータを分割して、学習＆テストに使う
    train, test = train_test_split(aggregated_train, test_size=param["data"]["TEST_SIZE"], random_state=param["run"]["RANDOM_STATE"])
else:
    # 投稿後のRunなら拡張した学習データは全部使う
    # テストデータはKaggleから入力されたものをそのまま使う
    train = aggregated_train
~~~


②ローカル環境で判別した結果を、テストデータに含まれるlabelを使って精度を計算する
今回のコンペでは、「LLMが書いたと推定される確率」を出力し、それをROC曲線で評価することになっています。したがって、ROC曲線の面積であるAUCを求めています。
~~~.py
# Local validation
if param["run"]["LOCAL_RUN"]:
    from sklearn.metrics import roc_curve
    import matplotlib.pyplot as plt

    y_test = test['label'].values

    # getting data for ROC curve
    fpr, tpr, thresholds = roc_curve(y_test, final_preds)

    # Plotting ROC curve
    plt.plot([0, 1], [0, 1], 'k--')
    plt.plot(fpr, tpr, label='ROC curve')
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.title('ROC Curve')
    plt.show()
    
    # AUC score
    auc = roc_auc_score(y_test, final_preds)
    print("AUC: {}".format(auc))
    param["auc"] = auc
else:
    param["auc"] = "no_auc"
~~~

余談ですが、ROC曲線を描いてみると下図のようになってます。ほぼ1じゃん。実際、手元の環境でAUCの値も0.999以上になることもしばしば。戦いは「どれだけ重箱の隅をつつけるか？」の様相を呈しております。（過学習との闘いでもあります。）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3666147/194ea23e-92b8-fe3a-853c-75098861e5c0.png">
<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3666147/8aae3120-0b38-ddab-49a5-d5fd34829b6a.png">
ROC曲線の隅をつつく私



# Out of Memoryに泣く
元のソースコードで使っている4つの分類器のうち、[MultinomialNB](https://scikit-learn.org/stable/modules/generated/sklearn.naive_bayes.MultinomialNB.html)と[SGDClassifier](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDClassifier.html)はとっても軽量で動作も速いのですが、[LGBMClassifier](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMClassifier.html)と[CatBoostClassifier](https://catboost.ai/en/docs/concepts/python-reference_catboostclassifier)が、めちゃくちゃ重たい感じになっていました。そのため、テストデータがダミーの状態では動作するのですが、テストデータを実データにしたとたんにOut of MemoryでNotebookがクラッシュします。

それなのに、なぜかSubmit環境では、テストデータが実データに置き換わっても正常に動作するのがナゾです。Notebook用に公開されている環境より、Submit環境の方が、若干リソースが多めにあるのかもしれません。とにかく、せっかく上記で作った「手元でのトライ＆エラーを回す仕組み」も、若干空振り気味です。現時点では、手元でRunさせる場合は、MultinominalNBとSGDのみを動かして、投稿する際に、LGBMとCatBoostもONにする、という形で「運用でカバー」しております。

ただ、これだと「手元の環境では動作したけれど、Submit環境でOut of Memoryエラーで止まる」ことが多々あるんですよねえ。今日は3連休の時間をつぎこんだ渾身の投稿が、5回中4回エラーになってノースコアだったので「キー」ってなっています。念のため、軽量な分類器だけで戦えないかともトライしてみたのですが、LGBMとCatBoostの効果はそれなりにあるので、ちょっと決別はできなさそうです。特徴量データのスリム化に取り組まなければ。

また、当初は「MultinominalNBとSGDのみでもパラメータの変化による精度の傾向は見えるだろう」と思っていたのですが、これも、上述のROC曲線で見たように過学習気味なので、あまりあてにはなりません。結局、いろいろと工夫してはみたものの、最終的には限られた投稿回数と、2～3時間かけて得られるLBスコアだけを頼りに、精度改善を行っていく羽目になっているのでした。

# オレオレMLOps環境の整備
元のソースコードをいろいろと改造していくうちに、実験条件として管理したいパラメータがいろいろと出てきました。先ほど述べたように、実験によって分類器をつけたり外したり、その重みを上げたり下げたり、前処理をやったりやらなかったり、などなど、いろいろパラメーター調整を行っていく必要があります。分類器構築の際に与える学習率や繰り返し回数など、「秘伝のたれ」のような味付けのマジックナンバーもわんさかあります。

これらのパラメーターは、しっかりと管理していないと、ちょっと油断した隙に「あれ、どうやってこの結果は出てきたんだっけ？」ということになってしまいかねません。かといって、いきなり全部のパラメータを管理下に置くのも大変なので、「精度に効きそうだからちょっと振ってみよう」と思えるパラーメータを一つのセルにまとめることにしました。

これもやったことは2つです。
①Notebookの最初のセル（または、import文が並んでいる次のセル）に、パラメーターをまとめて記載する
~~~.py
# パラメータの設定
param = {}

# 1. 実験全体に関するパラメータ
param["run"] = {}
param["run"]["VERSION"] = 29                           # Saving version of notebook (current version + 1)
param["run"]["LOCAL_RUN"] = True                       # True: local varidation / False: submission
param["run"]["RANDOM_STATE"] = 777                     # Common random state through an experiment
param["run"]["COMMENT"] = "Version 13 + VocabSize"

# 2. データセットに関するパラメータ
param["data"] = {}
param["data"]["TEST_SIZE"] = 10000                      # data split for local varidation
param["data"]["TRAIN_LIST"] = ["daigt", "daigt_ex"]       # dataset list for data aggregation

# 3. ベクトル化に関するパラメータ
param["vec"] = {}
param["vec"]["TF_IDF"] = True
param["vec"]["BINARY"] = False
param["vec"]["NGRAM_RANGE"] = (3,5)
param["vec"]["VOCAB_SIZE"] = 100

# 4. 分類器に関するパラメータ
param["clf"] = {}
param["clf"]["CLF_LIST"] = ["mnb", "sgd", "lgb", "cat"] # classifier models for ensemble
param["clf"]["WEIGHTS"] = [0.07,0.31,0.31,0.31] # weights for each classifier
    
# パラメータを表示
print("Parameters:")
pprint(param)

# パラメータ間の整合性をチェック
if len(param["clf"]["CLF_LIST"]) != len(param["clf"]["WEIGHTS"]):
    print('\n\nError: Length of parameters is not same: param["clf"]["CLF_LIST"] and param["clf"]["WEIGHTS"]')
    sys.exit()
~~~

②Notebookの実行後、パラメーターと精度の結果をファイルに保存する
~~~.py
# パラメータの保存と表示

now_dt = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
param["run"]["DATE_TIME"] = now_dt
run_env = "local" if param["run"]["LOCAL_RUN"] else "submit"

dir_name = "run_log/v" + str(param["run"]["VERSION"])
os.makedirs(dir_name, exist_ok=True)
file_name = f"{now_dt}_{run_env}.json"
path = os.path.join(dir_name, file_name)

with open(path, mode="wt", encoding="utf-8") as f:
    json.dump(param, f)
print("Saved: ", path)
print("Param:")
pprint(param)

# パラメータと結果の履歴を表示
file_list = sorted(glob.glob(f"run_log/*/*json"))
param_df = pd.DataFrame()
for file in file_list:
    with open(file, encoding='utf-8') as f:
        d = json.load(f)
    df = pd.json_normalize(d)
    param_df = pd.concat([param_df, df], join='outer')

param_df.reset_index(drop=True,inplace=True)
display(param_df)

# ダウンロード用にファイルを圧縮しておく
!zip -r run_log.zip run_log
~~~
![run_log.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3666147/d298b04e-0afb-38f8-e8c1-0def2b11986c.png)


こうしておくことで、実験の開始後に、今走っているのがどんな実験なのか確認できますし、実験後もどの条件でどんな検証結果が出たかを一覧で確認できます。その気になれば、jsonファイルを読み込んで、過去の実験の再現も可能です。（同じパラメーターセットを使っている限りは）
~~~.py
# 過去の実験条件を再現する
RE_RUN = False
run_log = "/kaggle/working/run_log/v14/20240107_011432_local.json"

if RE_RUN:
    param = {}
    with open(run_log, mode="r") as f:
        param = json.load(f)
~~~

あと、御多分に漏れず、Kaggleのノートブックもセッションが切れるとデータが消えてしまうので、ノートブックの実行後にはパラメータのログファイルが格納されたディレクトリrun_logを圧縮しています。「お昼ご飯食べに行こう」とか「今日は切り上げ！」と思ったタイミングでダウンロードしてローカルに保存しています。