自己紹介

能見氏:それでは「Time-series code competitionで生き残るには」というタイトルで発表したいと思います。

まずは自己紹介します。能見と申します。主に「@nyanpn」というIDでいろいろなところで活動しています。大阪で10年ぐらい開発を行っているソフトウェアエンジニアです。

Kaggleでは専らテーブルデータのコンペにばかり出ています。なぜか、スポーツとサイエンス系のコンペにばかり縁があって、(スライドを示して)直近に出たコンペ5個がこれです。

その5個のうち3個がTime-series code competitionという種類のコンペで、最近はTime-series codeコンペ大好きおじさんみたいになっています。今日はそのTime-series code competitionについて話したいと思っています。

Time-series code competitionとは何か

まず「それって何?」というところからなんですが、Kaggleには「Code competition」というコンペの形式があります。コードをKaggle側の環境で実行するコンペで、実行時間に制限があることが1つの大きな特徴です。

Time-series code competitionは、「Code Competition」の特殊なケースだと思ってもらえればだいたいOKです。何が違うのかというと、専用APIを経由してデータを入出力するところです。2019年からテーブルデータ系で4回開催されているコンペです。

この専用APIをもう少し掘り下げて説明したいと思います。(スライドを示して)これはRiiidコンペの例ですが、基本的にコンペ名と同じ名前のモジュールがホストから提供されるので、それをインポートして使いましょうというのがお作法になっています。

テストデータはこのモジュール経由でしかアクセスできませんし、予測値も提出できない仕組みです。

特徴的なのは、このAPIがデータを時間順に少しずつ渡してきて、未来の情報を使わせないコンペ設計になっています。ソリューションが実務にそのまま使えるかたちになりやすいため、私はすごくよくできた仕組みだなと思っています。

結局、何をやらされるかというと、ふだんのKaggleでは推論をバッチ処理して欲しいという話なのに対して、Time-seriesコンペでは「ストリーム処理する」という縛りが入っていると解釈できると思います。ただでさえコードコンペは面倒くさいのに、この縛りが追加されていて「なかなか大変そうだな」と思った方も、もしかたらいるかもしれませんが、実際大変です。

本質的に何が大変なのかというと、これは人やコンペによって意見が違うと思いますが、「私はこう思う」というのをあえて言い切ってしまうと、「ストリーム処理に対応しつつ、高速でロバストなコードを書くのが大変」だと思っています。

実際この1文に3つぐらいの要素が詰まっていますが、個別の要素というよりは、これらを全部一緒に、しかもそれをJupyter Notebookで書かなければいけないのが、Time-seriesコンペの大変なところだと感じています。

今日は別にみなさんをビビらせに来たわけではなくて、実際3回コンペをやってみて、「ポイントを押さえてしまえばぜんぜん怖くない」と私は思っています。

ある種の面倒くささみたいなものは間違いなくありますが、慣れてしまえば怖くはありません。今日の内容は私が個人的に思っているTime-seriesコンペで生き残るために重要な4つのポイントを挙げて、それぞれどうやっていくかを紹介したいと思います。

(スライドを示して)ここに挙げたこの4つと、さっきのストリーム処理がどうつながっているのかというと、前半2つがストリーム処理に対する処方箋になっていて、後半が主にロバストなコードを書くための説明になっていると思って聞いてください。

TrainとTestにどう両対応するか

まずは1つ目のTrain/Testの両対応という話からいきたいと思います。

これはどういう問題かというと、Time-seriesコンペではテストデータが基本的に1行ずつやってくるので、当然、前処理とか特徴量生成のコードも1行ずつ処理できるように書かないといけません。

これをふだんのKaggleのノリでTrain用のコード、特に特徴量生成のコードをpandasでゴリゴリ書いていると、いざsubmitしようとした時に「あれ、これテストデータに適用できないじゃん」とか、あるいは「ぜんぜん制限時間に収まらないぞ」とかいう話になってしまいます。ここで行き詰まることがすごく多いかなと思っています。

公開Notebookでは、実際にどう実装しているか見てみましょう。(スライドを示して)実装方針としてはだいたい以下の2つがよく見られています。1つはTrain向けに書いたバッチ処理関数をTestにもがんばって使う方法。もう1つは、そもそも別々のコードで分けて書く方法です。

1つ目の方法では、Test用の処理としてはオーバーヘッドが大きく、パフォーマンスに問題が出るケースが多いと個人的に思っています。

また、Trainデータに対しては1回しか呼ばないような関数を、Test向けではforループの中で何千回、何万回と繰り返し呼ぶことになるので、Testで死にやすいです。呼び出し回数の比率から言うと、TrainでうまくいったのにTestでは失敗しても何ら不思議ではありません。

一方で、実装を分ける2つ目の方法も相当茨の道です。何かアイデアを1個実装しようとするたびにTrain用とTest用の実装が入るので、単純に面倒くさいですし、バグも生みやすい。

ということで、個人的な意見としてはどちらもおすすめできません。コンペ初期の書き捨て前提の状態なら、上記の2つの方法でもよいと思いますが、長期間のコンペを戦い抜くにはどっちもデメリットがメチャクチャ大きいと思っています。

おすすめは全部ストリーム処理

ではどうするのか。私のおすすめは「全部ストリーム処理で書く」という方針です。要は1行ずつ処理するTest向けのコードをまず書いて、それをTrainにもTestと同じように1行ずつ適用して、全部ストリーム処理とみなします。

ナイーブにTrainの処理を1行ずつやると重いのですが、並列化したり、1回計算したらキャッシュしたりしておくと、たいてい十分だと感じています。

時間がないので、コードの紹介はほとんど載せるだけになりますが、(スライドを示して)これは実際にMLBコンペで私が書いた特徴量関数のうちの1個です。雰囲気としては、1行分の特徴量を4列分処理する関数になっていて、戻り値は辞書になっています。こういう関数をいっぱい書いて、これを1行ずつ呼び出しています。

デコレータで「@feature」みたいな飾りをしていますが、この中でエラーハンドリングを統一的に書いています。さっきの特徴量をまとめて1行ずつ呼ぶような関数を、Trainでも呼ぶようにしました。資料をアップしていますので、興味ある方はご覧ください。

こうすることで、当たり前ですが、Train/Testでの計算結果が確実に一致するようになります。それから、ストリーム処理前提でコードを書くので、推論時のパフォーマンスを考慮した書き方がしやすいと言えます。

これは地味に重要なのですが、さっきのやり方だと自ずとローカルでTrainの行数分呼び出すことになるので、勝手にコードが枯れて、自然とエラーが起きにくい状態になってくれます。本当はきちんとテストをしたほうがよいのですが、心理的には「まぁ、ローカルで1億回呼んだ関数だからTestでも大丈夫でしょ」といった安心感が出てきますね。

特徴量ごとに関数を当てておくと、テストと計測がしやすく、エラー時の被害の範囲が抑えられるといったメリットがあります。

ということで、最初はストリームを基本形に書く方法は、けっこう面倒だと思いますが、特に「GBDTでがっつり特徴量を量産してやるぜ」といったケースではおすすめできる書き方だと思います。

状態管理の設計はどうするか

次に、状態管理の話をしたいと思います。

例えば予測に過去の情報を使って、「直近何日の集計」の特徴量を作る場合に、Time-series APIから返ってきたテストデータの値をどこかに保存しておかないと、Test側で特徴量の計算ができません。なので、どこかで状態を管理する必要があります。

このテストデータをNotebook上のバラバラな変数に持たせていると、コードが増えると大変になりますし、チームマージのハードルもかなり上がると思います。

これに関しては、正直「こうすれば全部OK」といったものはありませんが、少なくとも状態を管理する場所は1ヶ所にまとめることをおすすめします。

あとは、こう言うと身も蓋もないかもしれませんが「どうデータを貯めるのかをしっかり考えて書きましょう」ということに尽きます。生データで貯めるのか集計するのか、そもそもメモリ全部乗るのかとか、そういうところを1個1個考えながら書いていきましょう。

あと、どういうデータ構造で状態を持つかという話にもちょっと触れておこうと思います。pandasにどんどん行を追記して状態を管理していってもよいと思います。ただ、私個人の意見としては、pandasは遅いコードが簡単に書けすぎちゃうところがあって、Time-seriesコンペにおいて堅牢で速いコードを書くには、ちょっとpandas全頼みはつらいなと感じています。

あと、私が単にpandasに詳しくないだけなのですが、想定外の挙動をしちゃうことが時々あって、ちょっとつらいなと思ったので、私は基本的にNumPyで全部の特徴量計算をしていました。NumPyをデータフレームっぽく扱って、カラム名でアクセスできる簡単なwrapperを自作して、それを使っていました。コードはGitHubで公開をしているので、興味ある方は見てみてください。

ここまでいろいろな話をしてきましたが、けっこうやることが多くて面倒くさいぞと思われた方もいるのではないでしょうか。実際のところ、この下回りをどれぐらい作り込まなければいけないかというのは、コンペと解法次第でケースバイケースだと思います。例えば特徴量をあまり作らずにNNのアーキテクチャ1本で戦う場合は、わりとNotebook1本で乗り切れるぐらい簡単なケースもあると思っています。

なので、最初はあまりsubmitのことを気にせずに、手元でどんどん書いて勘どころを掴んでから、推論向けの設計に腰を据えて取りかかるやり方を個人的にはおすすめします。

(次回へつづく)