エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Kaggle "Mechanisms of Action (MoA) Prediction" に参加し4位に入賞した話

はじめまして。 エムスリーエンジニアリンググループ/AIチーム 堀江です。

今回、データ分析コンペティションプラットフォームであるKaggleにて、2020年9月 ~ 2020年12月まで開催されていた Mechanisms of Action (MoA) Prediction に参加し、私を含む "Kanna Hashimoto with Friends" というチームで4373チーム中4位に入賞することが出来ました
(同時に、念願のgoldメダル取得でKaggle Competitions Masterになりました e-mon | Kaggle )。

f:id:eemon18:20210108201258p:plain
Private LBの結果

そこで今回は、参加記録をかねて、Code Competitionの取り組み方について紹介させていただきたいと思います。 本記事では主に、CodeComptitionとはなにか、どのように今回取り組んだのかについて紹介するため、 解法の詳細や、コード等は以下の記事をご覧ください。

4th Place Solution in Mechanisms of Action (MoA) Prediction

www.kaggle.com

github.com

Code Competitionとは

Code Competition とは、Kaggle上で行われるコンペティション形式の1つであり、従来のように予測値を含めた submission.csv の提出ではなく、予測値の出力を行う notebookの提出 を行うものです。

この形式の面白いところは、 実行環境 (CPU、メモリ、コンテナイメージなど) と、推論時の制限時間に制約が課されるというところです。これらの制約により、競技が公平であることがある程度担保される上、コンペティションを開催するホストにとっても、実行時に再現性が担保されるという嬉しさがあります。

また、通常のコンペとは異なり、test datasetを見ることができないという制約も一部加わります。
通常のコンペでは、test dataset全体が与えられ、一部が開催期間中も見ることが出来るPublic、期間終了後に実際のランキングに用いられるPrivateに分割されています。しかし、この形式のコンペでは、notebookを提出後にKaggle側で再実行され、Private LB用のtest datasetでの推論が行われる方式のため、test datasetがどのようなデータかは推測することしかできません。
そのため、Kaggleプラットフォーム上で推論が再現可能なだけではなく、まだ見ぬdatasetにも対応してなければならない、という現実世界のMLプロダクトにおける問題設定により近い形式となっているのも特徴です。

そんなCode Competitionですが、上述した制約のために様々な回避しがたい事態を引き起こします。

  1. ひとつのnotebook上で特徴量の作成やモデルの学習、推論までを通して行わなければならないため、コードは複雑怪奇になりがち
  2. 推論時間に制約があるため、特徴量生成やモデルの学習など重い処理をうまくキャッシュする必要がある

特にMoAでは、複数のモデルのEnsembleがとても有効だったこともあり2は大きなポイントだったのではないかなと思います。 この1, 2の問題について今回僕たちのチームが取り組んだ内容をご紹介します。

notebookが複雑になるのを回避する

Code Competitionでは、ひとつのnotebook上で全ての処理を行う必要があるために、データの読み込み、前処理、学習、...、etcとすべてのコードが統合された巨大なnotebookができあがりがちです。 それを読み解くのは書いた本人ですら困難なこともしばしばあり、コンペ最終週の数日はチームメイトのコードマージに費やすということもよくあると思います。かくいう私も、あるコンペではコードのマージに失敗してsubmit出来なかった苦い経験があります。

そこで、今回のコンペでは以下のような目標を立てました。

  1. IDE (vim) でコードを書けるようにする
  2. gitでコードを管理して差分を把握出来るようにする

1の達成自体は、最初から一枚のスクリプト ( .py ) として書いて、notebookにコピペしてしまうのが一番楽ではあるのですが、実行途中の結果を確認したりロジックを微妙に変更したりするのがちょっと面倒です (なによりセルごとに実行出来るというnotebookのよさがあんまり活かせていません)。
学習や推論時に用いるutility script群 (trainerなど) は .py スクリプトとして書きつつ、notebookにimportして結果を確認出来るようになるととても便利です。

今回私は、以前 iMet Collection 2019 - FGVC6 に参加した際に lopuhin さんが実装されていた、ファイルをBASE64 Encodingし、notebook上で復元する、という方法を参考にすることにしました (元実装は下記リンク)。

GitHub - lopuhin/kaggle-imet-2019

encodingを行うメインの部分は lish-moa/encode.py になります。

def encode_file(path: Path) -> str:
    compressed = gzip.compress(path.read_bytes(), compresslevel=9)
    return base64.b64encode(compressed).decode('utf-8')


def build_script():
    to_encode = list(Path('src').glob('**/*.py'))
    file_data = {str(path): encode_file(path) for path in to_encode}
    output_path = Path('.build/script.py')
    output_path.parent.mkdir(exist_ok=True)
    output_path.write_text(template.replace('{file_data}', str(file_data)).replace('{commit_hash}', get_current_commit_hash()), encoding='utf8')

実装はとても単純で、 .py スクリプトを全て列挙してencoding、encodeした文字列を含む復元用のスクリプトを吐き出しているだけです。
encode.py を実行すると、encode文字列を含み、 setuptools を介してscript群がimport可能な形になるようなコードが生成されるので ( .build/script.py )、これをコピペなどでkaggle notebook上で実行すれば、 localでの開発のときのように呼び出すことが出来ます。

iMetで用いられていたオリジナルの実装からの変更点は、単一の encode.py スクリプトで動くようにした点と、自分の確認のためにcommit hashを付加するようにした点です。
特にcommit hashを付加したのは、Base64 encodeした文字列の比較によってしかソースコードの変更がわからなかった部分が改善されたのでとても比較しやすくなりました。

上記と、KaggleのDocker image ( gcr.io/kaggle-gpu-images/python:latest ) を用いることで、local notebook上とまったく同じcodeをKaggle notebook上で動作させることができるようになり、 ipynb ファイルをKaggle notebookにuploadするだけでそのまま動くので、動作確認がとても簡単になります。

Kaggleには他にもUtility Scriptとして py ファイルをcommitしてnotebookからimportできるようにする機能がありますが、module として構造化することが難しかったり、ファイル数が増えると単純に読み込みが大変になる問題があるので、現状ではこのように書くのが一番楽かなと思います。

時間のかかる処理のキャッシュ戦略

Code Competitionに限ったことではないと思いますが、数十秒、はては数時間待つような処理を手軽にキャッシュ出来たら、ということはEDAやモデルの構築の段階でよくあるのではないかなと思います。 キャッシュすると一口に言っても、なにをどのようにキャッシュすればよいかはなかなか難しい問題だと思います。
例えば、当然の要求として同一の入力の場合は同一の処理結果が得られるようなキャッシュ機構を作りたくなりますが、 "同一の入力" というのをどこまで厳密にすればよいのかは要件により様々でしょう。 特に、浮動小数点かつ巨大な行列演算が多く登場する機械学習分野においては、同一性の判定が困難になりがちです。

唐突な宣伝ではありますが、弊社のAIチームで開発している gokart は機械学習のプロダクトを扱うPipelineとしてはとても魅力的なキャッシュ機構を備えており、Task間の依存関係を記述することで "巨大な行列を生成するTask自体をキャッシュする" ことができるため、入力の同一性の判定問題をうまく回避しています。

Kaggleでもgokartが使えればよいのですが、gokartどころかluigiさえもKaggle notebookのDocker imageには入っていないため、gokart (+ luigi) の機能を一部参考に、入力に応じてキャッシュしてくれるデコレータを書いてみました。

lishmoa/src/utils/cache.py

import time

@Cache('./cache_dir')
def process(param_a: int, param_b: str):
    time.sleep(5)
    return f'a: {param_a}, b: {param_b}'

上記のようにデコレータを付加すると、 param_aparam_b の入力に応じて固有のhash_idを作成し、dataframeであれば feather 方式で、ほかは pickle でファイルとして保存するという機能になっています。

# キャッシュとして process_2a7442a6c9fda50b4255ebe5b52748a6.pickle が作成され、初回は5秒かかる
# <function_name>_<hash_id>
In [1] process(128, 'a')
CPU times: user 2.48 ms, sys: 0 ns, total: 2.48 ms
Wall time: 5.01 s
Out[1] 'a: 128, b: a'

# パラメータから計算されたhash_idが同一のキャッシュファイルが存在する場合は保存されている結果が読み込まれる
In [2]: process(128, 'a')
CPU times: user 1.05 ms, sys: 234 ns, total: 1.29 ms
Wall time: 1.12 ms
Out[2]: 'a: 128, b: a'

# 異なるパラメータの場合は、再度処理が行われる
In [3]: process(256, 'b')
CPU times: user 2.16 ms, sys: 446 ns, total: 2.6 ms
Wall time: 5.01 s
Out[3]: 'a: 256, b: b'

このように、初回実行時は、 param_a=128, pram_b='a' に対するhash_id 2a7442a6c9fda50b4255ebe5b52748a6 のキャッシュが存在しないため5秒かかりますが、同一hash_idのキャッシュが存在する場合は pickle ファイルが読み込まれる、という処理です。
実際の notebook を見るとわかるように、学習と推論をわけることなく、キャッシュの有無に依存しない同一の処理内容を書くことができるようになります。

まだまだ同一性の判定部分や、File I/Oの部分など処理的に甘いところなど課題は多く残っていますが、今後もコンペに参加することでブラッシュアップしていけたらなと思っています。

ちなみにpythonには、 lru_cachejoblib.Memory などの実装が存在し、広く使われていると思いますが、今回は、巨大なサイズのdataframeやndarrayに対応できて、かつKaggle notebook上で実行可能なくらい薄いコードにしたい、という欲求からこのような形のデコレータとなりました。 もし、他のライブラリなどでも同様のことがもっとうまく出来るよ、ということがあればぜひ教えて下さい!

まとめ

本記事では、MoAへの参加を通じて得られたCode Competitionへの取り組み方のTipsを紹介しました。 実験を重ねていく中で、コードや実験結果が散らからないようにする工夫は皆さんそれぞれされているとは思いますが、同じような悩みを抱えている方の参考になれば幸いです。

We are hiring!

エムスリーでは、Kaggleによって得られたEDA力やモデリングなどの知識・経験を生かして機械学習プロダクトを一緒に推進していく仲間を募集しています!

エンジニアリンググループ 募集一覧 / エムスリー

jobs.m3.com