Optunaチューニングフローのコード解説

鈴木隆史氏(以下、鈴木):では、コードの解説に移ります。(スライドを指して)まずは、こちらのジョブです。Kubeflow Pipelineのクラスターにジョブを立ち上げる部分のコードを説明します。このメソッドでは、作成したパイプラインをコンパイルして、format_resource_setting()からリソースを指定して、run_pipeline()でKubeflowのクラスターにジョブをデプロイする。ローカルからKubeflowのGKEのクラスターにジョブをデプロイしています。

実際にデプロイされたジョブがどうなるかというのが次のページです。(スライドを指して)これがcreate_optuna_pipeline()の中身です。@kfp.dsl.pipelineというデコレーターを使うと、パイプライン関数としてKubeflowのジョブを定義することができるので、こちらを使っています。そのほか、ExitHandlerにSlackの通知で使うオペレーターを指定することで、そのクラスター内で実行したジョブが、成功・失敗したことを「Slack」に通知できるようになっています。

次に、create_optuna_op()の関数の中身ですが、(スライドを指して)これはOptunaのジョブを実行するためのコンテナオペレーターを生成しているコードです。タスクはコンテナ単位で実行されるので、ここで任意のDockerのイメージを指定して、パイプライン上にデプロイしています。このオペレーターには、Sidecarコンテナというものを指定することができます。Cloud SQLを使った方ならわかると思いますが、Cloud SQLにプロキシ経由で接続することが推奨されているので、今回はこのSidecarというコンテナを使ってCloud SQLのプロキシを指定しています。

また、チューニングの実行の処理は、オペレーターに関数として渡さずにDockerのイメージに閉じ込めているので、疎結合としてパイプラインを実行するコードと、中身のシミュレーターを実行するコードを分離して管理しています。

Optunaで最適化が行われるコード

次に、実際にOptunaで最適化が行われるコードを解説します。(スライドを指して)このあたりの処理です。目的関数で営収を計算して、それをストレージからとって、どう処理していくかという部分です。(スライドを指して)まず、オペレーターで実行されているコンテナは、このメソッドで実行されています。今回は分散処理のため、create_study()のストレージ引数にCloud SQLを指定しています。Sidecarで Cloud SQL Proxyを立てているので、それを通してGCPのMySQLのサーバーに接続できます。

(スライドを指して)ここが本題の目的関数の中身で、1つの試行ごとに行われている処理です。まずは、前半の部分です。今回はValue Iteratorというコンポーネントを通して最適化したいパラメーターが複数ありその処理によって使いたいconfigが一つひとつ異なるので、getattr()の関数を使って地域ごとやパラメーターごとにサジェスチョンしています。パラメーターごとの分布や探索範囲は、Configファイルに事前に持っておいています。

次に、create_objectからすでに正常に完了したTrialの数を集計して、最大試行数を超えていないかチェックしています。今回は100件ですが、100件を超えていないかを、ちょっと力技ですがチェックするようにしています。これはOptunaの問題ではなくシミュレーター側の問題です。シミュレーターが完了するまで待機するというジョブがありますが、けっこう失敗します。実はもう試行回数が100件を超えてしまったので、そのエラー判断のためにこのようなイレギュラーな対応をしています。

ただ、このような処理はOptunaのstudy.optimize()の引数にコールバック関数を指定することができます。実際は、そのコールバック関数の中でTrialの完了数を調べれば途中でStudyを止める処理もできるので、今後はそちらに引っ越ししたいと思っています。

次に、後半の部分です。(スライドを指して)1つはrun関数によってシミュレーションのパイプラインを生成して、Kubeflow Pipelineにデプロイする処理を書いています。この部分は既存のものをそのまま使っているので詳細な説明は省きますが、シミュレーションで述べたように、ただパイプラインを立ち上げているだけです。シミュレーションのパイプラインを立ち上げてはいますが、今回はOptunaのパイプラインからシミュレーションのパイプラインを立ち上げる二重の管理というか、二重にパイプラインを立ち上げる少し冗長化した処理です。

最後に、Optunaのジョブのデプロイとは違って、runパイプラインのあとにシミュレーションが完了するまで待機する処理が入っていて、その処理が完了すると、実際にドライバーのシミュレーターでどれくらい営業収益があったかを集計して、BigQueryにデータを保存しています。これが目的関数の1つのTrialで行われる処理です。

実験と評価結果

次に、実験と評価について説明します。ここでは、解説してきたフローを用いてチューニングをして、実際にパフォーマンスが収束しているかを管理して評価しています。今回はチューニングされた新しいパラメーターと、今現行で、本番で動いているパラメーターのパフォーマンスを比較するために、探索のパラメーターを3つ定義しています。これは、アルゴリズムエンジニアの方に聞いて、営業収益への影響が大きいものを3つ定義しています。

1つ目が、割引率のガンマ。2つ目が、道路別の重みです。例えば、左折しやすい道や右折は少し難しい道のように、タクシードライバーが行きやすい道と行きづらい道があります。3つ目として、流しのお客さんが乗車するまでにかかる最大の待ち時間があります。以上をハイパーパラメーターでチューニングしています。ほかにもいろいろなパラメーターがありますが、今回の実験では、この3つの影響が大きいパラメーターを定義してチューニングしています。

学習期間とテスト期間は分けて、新旧パラメーターとMLモデルと統計値という4つのパターンで営業収益を比較しました。この統計値は、MLのモデルが30分後のものを予測しています。最初に話しましたが、その30分後の営業収益を予測する際にインフラ側のアクシデントのほか、モデルが重くなって30分後に終わらなかったことがあるので、そのような時のコールバックとして統計値を利用しています。そのため、今回はこの4つのパターンで営業収益を比較しています。

結果として、MLモデルの収入は平均だいたい1.4パーセント増加し、統計値は営業収益が2.2パーセントほど増加しました。これがどれくらいのインパクトかというと、アルゴリズムエンジニアが新しい施策でアルゴリズムやモデルを変更してうまくハマった時がこれくらいの上昇率なので、それと同等の成果を上げることができました。実際に、そのあとQA環境で引かれる経路を確認して問題がないことも確認できたので、現在は本番環境にチューニングしたパラメーターをデプロイしています。

課題と今後の展望

最後にまとめです。1つ目は、Optunaの使いやすさについて。今回はアルゴリズム的な話はあまりなくて、システム的な話がメインでしたが、OptunaのStudyやTrialのObjectに必要な情報がかなり揃っています。これまで行った試行情報や、最もパフォーマンスがよかった情報などが簡単に取得できるので、目的関数の細かい処理やチューニングのあとの分析がしやすかったと思います。

あと1つ、MLエンジニアとしてシステムの処理をうまく書くという目的があるので、ローカル環境でOptunaを実行するのも、もちろんチューニングの価値は十分ありますが、やはりクラウドインフラ環境を用いてワークフローや外部DBを組み合わせることによって並列分散処理して、ハイパラチューニングの時間を下げたり、時間を短くしたりできたのはすごくよかったと思っています。また、パラメーターや報酬の管理をシンプルにするために、現在はパイプラインのコードと強化学習の評価をするコードを疎結合にしていて、それもよかったと思っています。

次に課題点です。よかった点の裏返しでもありますが、まずはコードを疎結合することによる弊害です。現在は7日間の営業収益のシミュレーションを5並列、最大100試行で実験していますが、そうするとKubeflow Pipelineのジョブが大量に生成されてしまうので、その影響であとから分析したりパラメーターを検索したりする時に不便だと思うことがあります。うまく管理する方法があるかもしれませんが、現状はジョブが大量に生成されてしまうという弊害があります。

もう1つは、並列実行による弊害です。シミュレーションを実行するGKEのnode poolがオートスケールする前に、ほかのシミュレーターを5並列実行することによってリソースを奪ってしまうことがありました。これはnode poolを分けることによってある程度緩和できますが、シミュレーターのpodが立ち上がりすぎている場合は、ほかのことが立ち上がりづらかったり、CPUが奪われてしまったりすることがあるので、そこは注意が必要かと思います。もう1つ、試行時にスケールしないコンポーネントがあると、ほかがうまくいったとしても、その影響で足を引っ張ってしまうことがあります。

次に、今後の展望です。精度面に関しては、まだ工夫の余地があると思っています。Trial数などはもちろんですが、今はパラメーターの数が3つと少ないので、そこをどうやって、Prunerモデルなどを使って増やせるか。ほかに、今は割と古い期間でチューニングすることもあるので、チューニングする期間をより直近にするとパフォーマンスにどのような影響の変化があるかを確認していきたいと思っています。システム面に関しては、今5並列でジョブを実行していますが、より時間を短くしたい時に、並列数を上げた時に、どのへんがボトルネックになるのかを確認していきたいです。

ほかに、今新しい地域でお客さま探索ナビをデプロイする時、Optunaのチューニングを実行するのみにとどまっているので、定期的に、例えば半年経ったというタイミングでハイパーパラメーターを定期的にワークフローでチューニングし、今の現行のパラメーターと比較して、どちらが高いのかを判断する自動化を今後していきたいと思っています。

発表は以上です。ご清聴ありがとうございました。「We are Hiring!」として採用もがんばっていますので、もし興味ある方がいたらご連絡ください。

質疑応答

司会者:ありがとうございました。僕はメルカリという会社にいますが、同じブログ記事などを見て実装しているので、すごく参考になります。早速、質疑応答に入ります。「実験環境ではKubeflow、本番環境ではAirflowと使い分けているのはなぜでしょうか?」という質問が来ています。

鈴木:理由の1つは、Airflowのほうがパイプラインとして柔軟な操作ができるため、プロダクションでの利用がしやすいということです。失敗したジョブをリランする時に、Kubeflowは失敗したところからよしなにリランしてくれますが、Airflowだともう少し前のジョブからリランを手動で実行できます。ですので、Kubeflowだと、イメージが更新されていて途中のジョブからリランしたい時にけっこう不便なことがあります。そういった意味で、現状の本番環境はAirflowになっています。ほかに、Variableの機能がAirflowにしかないのも理由の1つです。より本番向きなAirflowと、より見た目がリッチで実験向きなKubeflowを使い分けているのは、それが理由です。

司会者:僕から1つ質問します。Kubeflow Pipelineで1つパイプラインを立ち上げて、それを親のようにして子どものパイプラインを立ち上げていくというシステム構成だと思いますが、それにした理由は何ですか?

鈴木:親パイプラインをローカルで実行すると待ち時間ができてPCも重くなるので、全部クラスター上で実行したかったんです。親のパイプラインの中に並列で子どものパイプラインを実行することも、もちろん技術的には可能でしたが、そうするとコードが密結合になって、子どものコードを変えると親のコードにも影響すると思いました。なるべく既存のシステムに乗っかるという意味で、親と子どもを分けてロジックを分離しているということです。

司会者:とてもよく理解できました。これで質疑応答を終わります。鈴木さん、ありがとうございました。