Hydraで書かれたコードをVertex AI Pipelineで動作できるようにした

背景

こんにちは!JX通信社シニアMLエンジニアのファンヨンテです。 JX通信社では 属人化しがちなR&Dをチーム開発するため社内共通のテンプレートコードを用いて機械学習が行われています。テンプレートコードにはハイパーパラメータ管理のパッケージとして、Hydraを用いています。

しかし、HydraとVertex AI (GCPのマネージドMLサービス)の相性が悪い部分があり、工夫なしではエラーになることがあります。 前回のブログでは、Hydraで書かれたコードでVertex AIのハイパーパラメータ調整を行うための工夫とサンプルコードを紹介しました。

tech.jxpress.net github.com

本ブログでは、ML Pipelineの簡単な紹介の後に、Hydraで書かれたコードをVertex AI Pipelineで動作できるようにするための方法を記載しています。またサンプルコードも公開しているので、一人でも多くの人の参考になれば幸いです。

github.com

ML Pipelineについて

ML Pipelineとはなにか?

機械学習のトレーニングは、データの前処理、学習、評価等様々なプロセスによって構成されています。 これらのプロセスを、一つのマシーンまたはコンテナで行う学習システムはMonolith システムと一般的に言われます (図1 (a))。機械学習を初めて体験したときには、多くの方が Monolith なシステムで試したのではないかと思います。

一方、機械学習系のシステムの本番運用まで考慮したとき、

  • データとモデルの再現性の担保
    • 例 : それぞれのプロセスにランダム性が含まれると、すべてのプロセスを一貫して行うMonolith システムでは、結果が変化した要因がつかみにくい
  • プロセスごとに求められるマシーンスペックが異なる
    • 例 : ハイメモリが必要なプロセスもあれば、メモリではなくGPUが必要なプロセスもある
  • プロセスが独立しているので、使い回しが容易

等の理由から、個々のプロセス (コンポーネントと一般的に呼ばれる)を独立させて処理を行う、Pipelineシステムによる学習が推奨されています (図1 (b))

図1 学習システム。 (a)Monolith システムでは、前処理や学習、評価等のプロセスをすべて、同じマシーンまたはコンテナで行う。(b) Pipelineシステムでは、各プロセスを分け独立したリソースで実行する。各プロセスは一般的にコンポーネントと呼ばれている。Pipeline システムでは、コンポーネント間のデータは外部のStorageやDBを経由して行われる。

ML Pipelineについてのより詳しい情報は what-a-machine-learning-pipeline-is-and-why-its-importantFull Stack Deep Learningを御覧ください。 また、Googleのブログ (Rules of Machine Learning:Best Practices for ML Engineering)では、MLの学習はPipelineの利用が前提に書かれています。

Vertex AI pipelineとは?

学習の Pipeline をコンポーネントに分け、それぞれを異なるマシーンで実行するようなシステムをゼロから構築することは、非常に複雑で困難であることが想像できると思います。 一方、Vertex AI Pipelineを利用することで、図2に示すような GCP の他のサービスと連携しながら、ML Pipelineを容易に構築することができます。

図2 Vertex AI Pipelineのシステム例。 Docker ImageはArtifact RegistryやContainer Registryで管理し、各コンポーネントはGCE等のリソースを用いて処理する。学習データやAIモデル等はGoogle Cloud Storageに保存ができる。Vertex AI Pipelineを用いることで、これらのGCPサービスと連携しながらML Pipeline構築を容易に行うことができる。

Vertex AI Pipeline のその他メリットや、より詳細な部分については、以下のような素晴らしいブログやsample codeが公開されているので、是非ご覧になってください。特に著者がML Pipelineに入門する際、杉山様のブログを理解し、サンプルコードを手元で動かすことは、たいへん大きな成長につながったので、ぜひ皆様も一度サンプルコードを手元で動かしてみてください!Vertex AIを用いることで、ML Pipelineの構築を楽に行えることが体験できると思います。

HydraとVertex AI Pipeline

Hydraはハイパーパラメーター管理のライブラリとして、非常に素晴らしく、こののように様々な学習用のコードが取り組まれています。 一方、Hydraで記載されたコンテナを、Vertex AI Pipelineのコンポーネントとして利用しようとした場合、問題が発生します。

問題点

Vertex AIでは、各コンポーネントにわたす引数を、yamlファイルのargsで定義します。 この際、Vertex AIの公式の書き方では

    command: [python3, main.py]
    args: [
      --project, {inputValue: project},
    ]

のように記述する必要があります。 このように argsを記述した場合、コンテナには以下のような argparse形式のコマンドが渡されます。

python3 main.py --project <value of project>

一方、Hydraを用いたコンテナには

python3 main.py project=<value of project>

の形式でコマンドを引き渡す必要があり、工夫なしで実行するとエラーになります。

解決法

yamlファイルの書き方を

    command: [python3, main.py]
    args: [
      'project={{$.inputs.parameters["project"]}}',
    ]

に変更する必要があります (図3)。

図3 YAMLファイルのコマンドを修正する理由と方法の概要図. Hydraで書かれたで公式のコーディングスタイルを使用するとエラーが発生します。エラーを回避するためには、コードを書き換える必要があります。

Vertex AIの一般的に利用される引数と、それに対応する変換方法は表1に掲載しています。


表1 : Vertex AIで推奨されている引数の渡し方をHydra用変換する対応表

公式の書き方 Hydra形式に変換する方法
--input-val, {inputValue: Input_name} input-val={{$.inputs.parameters['Input_name']}}
--input-path, {inputPath: Input_path_name} input-path={{$.inputs.artifacts['Input_path_name'].path}}
--output-path, {outputPath: Output_path_name} output-path={{$.inputs.artifacts['Output_path_name'].path}}

実際に動かしてみた

シンプルな例として、MNIST分類のAI PIpelineの構築を紹介します。sample codeはこちらで公開しております。READMEにコードをベースとした具体的な動作方法を記載したので、ぜひ皆様体験してみてください。

Pipelineは、

  • data prepare : MNISTのデータをダウンロードする

  • train : 学習を行う

の2つのコンポーネントから構成されます。 どちらのコンポーネントもHydraで書かれています。

図4 本ブログで作成するPipeline

各コンポーネントのコンテナイメージを作成

data prepare

data prepareのコンポーネントはこちらで記載されています。このコンポーネントも管理を容易になるようHydraで記載しています。 具体的には、必要な関数を functions フォルダに書き込みます。その後、config.yamlで処理したい関数とその引数を記述することで、処理する関数をパラメーターとして決定することができます。

train

学習コンポーネントは前回ご紹介したHydraとPyTorch Lightningを用いたコードを用いて行いました。

各コンポーネントの設定を行う

コンポーネントの設定はconfig フォルダで行われています。configの書き方は公式のドキュメント、または、Kubeflowのサイト参照ください。

ここの注意点として、argsは公式の書き方をしてしまうと、エラーになるので、表1のように書き直しが必要です。

各コンポーネントの接続

定義したコンポーネントの接続はpipeline.pyで定義され、コンパイルが行われます。 コンパイルで作成されたjsonをVertex AI Pipelineに提出すると、Pipelineが実行されます。

まとめ

今回はHydraで書かれたコードをVertex AI Pipelineで動作できるようにするときの問題点と工夫について記載させていただきました。 このブログが皆様にとって参考になれば幸いです。

Sentry で Go 製アプリケーションのエラーを楽に管理する

*1

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 今回はSentryというエラー集約管理システムをGo言語で扱う場合の知見を共有したいと思います。

Sentry とは

Sentryはエラーの集約管理を行うためのシステムで、作成したアプリケーション内で発生したエラーを一括で収集して見やすく管理することができます。

sentry.io

類似のエラーをグルーピングして発生頻度を確認したり、エラーの発生状況をSlackのようなチャットツールに通知してくれたりします。 予期しないエラーが発生したとき、生のログを見なくてもSlackやWebのUIで確認出来るのはとても便利です。 バックエンドからフロントエンドまで幅広い言語に対応しているためシステムのエラーを一括で集約が可能です。

JX通信社ではエラー管理ツールとしてSentryを広く利用しており100を超えるシステムが登録されています。 Sentryはセルフホスト版のSentryも存在していて、当初はサーバーを構築して利用していたのですが運用の煩雑さからマネージドなサービス版を利用するようになりました。

sentry-goを使ってみる

SentryのGo言語向けSDKの sentry-go、触ったことない方もいると思いますので、まずは使い方を軽く紹介します。

github.com

package main

import (
    "log"
    "time"

    "github.com/getsentry/sentry-go"
)

func main() {
  // SDK初期化
  // Options については後述
    err := sentry.Init(sentry.ClientOptions{})
    if err != nil {
        log.Fatalf("fail to init sentry: %s", err)
    }
  defer func() {
    // panic の場合も Sentry に通知する場合は Recover() を呼ぶ
    sentry.Recover()
    // サーバーへは非同期でバッファしつつ送信するため、未送信のものを忘れずに送る(引数はタイムアウト時間)
      sentry.Flush(2 * time.Second)
  }()

  // なにかのプログラム
  err = hoge.Exec()
  if err != nil {
     // err を sentry へ送信する
     sentry.CaptureException(err)
  }
}

このような形でSDKをセットアップすると、エラーやPanicが発生した場合にSentryへとデータが送られます。

Recover() は内部でGo言語の recover() を呼んでpanicから脱しSentryへの送信を行います。 なのでゴルーチンとかで処理が分岐している場合はゴルーチン側でも sentry.Recover() を呼ばないと捕捉できないケースがあります。

APIサーバーの場合はSentry側でWebFrameworkのインテグレーションを用意してくれています。 ユーザーのリクエスト情報(パスやメソッド、リモートアドレスなど)をエラーと合わせて記録してくれるので原因の調査がしやすくなるので利用可能であれば使ってみましょう。

おすすめのClientOptions

Sentryの初期化時に渡せるClientOptionsについて参考程度におすすめの設定を紹介します。 詳細については公式ドキュメントを参照してください。

  • DSN:
    • Sentry のDSNを入れるオプションです。
    • プロジェクトごとに専用のDSNが発行されるので、これをソースコードにセットすることで正しくセットアップができます。
    • が、ソースコードから設定しなくても SENTRY_DSN という環境変数をSDKが参照してくれるのでセットせず利用することが多いです。
    • DSNが未設定の場合でもエラーにはなりません。
      • 未設定時の場合、sentry.CaptureException()などを呼んでも何も起こりません。
      • Debug を有効にするとEventを破棄したログが出ます。
    • ローカルの開発環境ではSentryを使わず開発し、検証用のサーバーにあげるときにSentryのログを有効にする使い方が簡単にできます。
  • Environment:
    • 環境を入れるオプションです。
    • staging production などを入れておくと本番で起きたエラーなのかがすぐ分かります。
    • この Env を元に通知する Slack のチャンネルを変えるといった使い方も可能です。
  • AttachStacktrace:
    • CaptureMessageを呼んだ時にスタックトレースを付与するかのオプションです
    • ソースコードの位置がわかるので基本付けておいて損はないかと。
  • IgnoreErrors:
    • 文字列がマッチするエラーを無視することが出来ます
    • Sentry はサーバーにエラーを飛ばした数で料金プランが変化するので、対処する必要は特にないけれど大量に出てしまうエラーなどはここでセットしておくと安心です。

他にもパフォーマンス計測用のオプションなどがありますが今回は割愛します。

Goでの困りごと

エラーの収集は上記で出来るのですが、Go言語の場合すこし困ったことが起こります。

Go 1.13からエラーの Wrap 機能が提供されるようになり、捕捉したエラーをfmt.Errorfでラップして上位に返すパターンをよく書くようになりました。

...
err := json.Unmarshal([]byte(`{"wrong json"}`), &result)
if err != nil {
    return fmt.Errorf("fail to parse result: %w", err)
} 
...

ラップをすることでエラーの抽象度があがり、呼び出す側が意図を把握しやすくなります。

ところがこうしてラップされたエラーをSentryに渡すと

このような形で処理されてしまいます。 実際に発生しているエラーは json の Unmarshal のエラーのはずですが、画面の方では *fmt.wrapError のエラーとして扱われ、ソースの情報もエラー発生箇所ではなく sentry.CaptureException(err) を呼び出した箇所になってしまっています。

エラーの原因を調査をするとき、CaptureExceptionを呼び出した箇所というのはあまり重要ではなく、実際にWrap前のエラーが発生した箇所や、fmt.Errorfでラップを行った箇所を知らせてくれるほうが有用です。

Python版のSentryの場合、SDKをセットアップすればよしなにスタックトレースを出してくれたのですが、Goの場合、言語の標準エラーにスタックトレースの機能が存在しないため、少し不便な出力になります。

またエラーが「*fmt.WrapError」として扱われてしまうと、fmt.Errorf でラップしたエラーがすべて同列のエラーとして集約されてしまう問題もあります。Sentryの場合、エラーのグループごとに通知を設定することが多いので、異なるエラーが同一にまとめられてしまうと重要なエラーを見逃してしまうリスクがあります。

このあたりもPython版だとSDK入れるだけだったのですが静的言語なのでいろいろ制約がありそうです。

  • エラーが発生した箇所のソースコードがSentryで見られる
  • fmt.Errorf でラップした異なるエラーは異なるエラーとして扱われる の2つが実現できればSentryでの取り扱いがよくなりそうです。

Go でも Sentryを見やすくする

スタックトレースについては pkg/errors など外部エラーパッケージを利用すると、Sentryがそこからスタックトレースを取り出して表示してくれることが分かりました。

  • pkg/errors
  • xerrors
  • go-errors/errors
  • pingcap/errors

ドキュメントとして明文化されていないものの、現在この4つのパッケージに対応してそうです。

https://github.com/getsentry/sentry-go/blob/master/stacktrace.go#L75

pkg/errors は有名なエラーパッケージでしたが、現在レポジトリがArchiveされてしまっているため新規では利用しづらいです。

xerrors はGo公式がメンテナンスしているエラーパッケージです。 Wrap などの機能が Go 1.13 で本体に取り込まれたものの、スタックトレースの機能については取り込まれなかったためパッケージが残っています。スタックトレースのみを表示する用途であればこのパッケージで十分そうです。

xerrors でスタックトレース

...
err := json.Unmarshal([]byte(`{"wrong json"}`), &result)
if err != nil {
    return xerrors.Errorf("fail to parse result: %w", err)
} 
...

fmt.Errorf の部分を xerrors.Errorf に変えてみました。

今度は sentryでエラーを投げるところだけではなくてちゃんとエラーが発生したところをトレースできてますね。

じゃあソース内の fmt.Errorf を一括で変換してしまえばいいかというとそうでもなくて、今度は過剰にスタックトレースが付与されてしまいます。

xerror でラップしたものを更に xerror でラップすると、それぞれに対して別々のスタックトレースが付与されてしまい、Sentry ではそれらを全部列挙しようとします。本当に見たい部分以外のスタックトレースが入ってしまうので調査のための情報が逆にノイズになってしまいます。

なので、以下の方針でラップすると無駄のないエラーハンドリングが出来るかと思います。

  • Goのパッケージやライブラリが吐くエラーは xerror.Errorf でラップする
  • 自分で書いた関数のエラーをラップするときは fmt.Errorf を使う

xerror のスタックトレース付きエラーを fmt.Errorf でラップしてもちゃんとスタックトレースを展開してくれ、fmt.Errorf した部分もトレースが残っていくので上記の方針で進めると無駄なくエラーにスタックトレースを付与できると思います。

wrap したものを適切にグルーピングする

fmt.Errorf でラップしたものがSentry上でまとめられてしまう問題についても見ていきます。

https://github.com/getsentry/sentry/issues/17837

GitHubのIssueにヒントがないかみてみたのですが明確な解決法が見当たらず、SDKでタイトルを上書きするのがいいのではないかというコメントで終わっています。

GoのSentrySDKにはBeforeSend というフックが用意されており、こちらを利用することでタイトルの書き換えが可能になるみたいです。

  • グループ名(上段太字): *xerrors.wrapError
  • サマリ(下段): fail to parse result: invalid character '}' after object key

となっているところを

  • グループ名: fail to parse result
  • サマリ: invalid character '}' after object key

のように書き換えてあげれば、最後にWrapしたメッセージがグループ名として利用できそうです。

BeforeSend のシグネチャは

BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event

のようになっており、渡されたeventを書き換えてreturnしてあげれば上書きが可能です。

Sentryの captureException を利用した場合、Sentry側でエラーのグルーピングのキーになるのは event.Exception 配列の最後の Type が利用されるみたいたいです。 なので wrapError の場合だけ上書きしてみることにします。

err := sentry.Init(sentry.ClientOptions{
        Debug:            true,
        AttachStacktrace: true,
        // BeforeSend のフックで Event を書き換え
        BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
            for i, _ := range event.Exception {
                exception := &event.Exception[i]
        // fmt.wrapError, xerrors.wrapError 以外は何もしない
                if !strings.Contains(exception.Type, "wrapError") {
                    continue
                }
        // 最初の : で分割(正しく Wrapされていないものは無視)
                sp := strings.SplitN(exception.Value, ":", 2)
                if len(sp) != 2 {
                    continue
                }
        // : の前を Typeに、 : より後ろを Value に
                eexception.Type, exception.Value = sp[0], sp[1]
            }
            return event
        },
    })

先ほどのエラーを再度送信してみると、

無事エラーを書き換えることが出来ました。

今回のケースでは一律で一番外側の Wrap を剥がしていますが、処理を変えればより柔軟にカスタマイズできそうです。

まとめ

  • Sentry は便利
  • エラーを文字列でラップして使うときは以下のルールにするとスタックトレースが扱いやすい
    • Goのパッケージやライブラリが吐くエラーは xerror.Errorf でラップする
    • 自分で書いた関数のエラーをラップするときは fmt.Errorf を使う
  • BeforeSend のフックでイベントを加工することで有意なエラーグルーピングを実現できる

参考

*1:The Gopher character is based on the Go mascot designed by Renee French. The design is licensed under the Creative Commons 3.0 Attributions license.

AI開発時に、あるClassのデータを集中的に追加する時のTipsと注意点

こんにちは!JX通信社シニアMLエンジニアのファンヨンテです。私は普段から、顧客にとって価値の高い(言い変えれば、顧客を幸せにできる)AIを開発することをモットーに仕事に励んでます。そのためには、AI開発の速度を高め、様々なAIやその見せ方を顧客に試していただくことで、顧客の幸福度を上げる方向性を見出していく必要があります。

効率性を高め、AIの開発速度を上げるために、JX通信社では様々な工夫を行っており、これまでもブログで紹介してきました。

tech.jxpress.net

tech.jxpress.net

tech.jxpress.net

今回はその取り組みの中でも、あるClassのデータを集中的に追加する時の背景やTipsと注意点についてまとめます

目次

背景

分類をおこなうAIの開発において、とあるClassだけ学習データの数が少なかったり、新たに分類しなければならないClassが追加されることはよくあります。このような場合の対策として、「とあるClassの学習用データだけを追加で作成し、AIの精度を上げたい」と考えることがあると思います。

例として、スイーツに関するアンケートの回答文を、

  • Class 1 : 『甘さに関する言及をしている』
  • Class 2 : 『しょっぱさに関する言及をしている』
  • Class 3 : 『味に関する言及なし』

の3つのClassに分類するAIがありました。ビジネス的な要望で、新たに

  • Class 4 : 『辛さに言及している』

のClassを追加する事になりました (図1)。しかし、これまで辛さに言及しているテキストにラベルを付けてなかったので、辛さのデータが少ないです。そこで、新たに辛さに言及しているテキストを収集し、Class 4の学習用データに追加していく状況を考えます(図1)。

図1 背景の例。テキストから『甘さに関する言及をしている』、『しょっぱさに関する言及をしている』、『味に関する言及なし』のテキスト分類していたが、『辛さに関する言及をしている』が追加されたり、データが少ない場合がある。

データ作成の手順

簡単なフィルター

アンケートデータの中から、『辛い』という意図が含まれるテキストを取得するために

(からい OR 辛い OR からっ) が含まれる

という条件でフィルターして集めるとします。しかし、このような条件で集めたデータには

ここ最近、本当に辛いから運動して気分転換しよ

のように「味」としての意味ではなく、「つらさ」を意図したものが含まれてしまう可能性があります。これらは本来 味の言及なしのラベルに該当し、辛さに言及している 学習データとして追加するにはふさわしくありません。

このようにキーワードマッチング程度のフィルターでは誤ったラベルが混入する可能性が高いため、そのまま学習データに加えるのはリスクがあります。 *1

Classにふさわしいデータを判別するシステムを作成する

簡単なフィルタリングだけでは学習データに適さず、その中からClassにふさわしい学習データ( 味としての辛い のデータ)を選び出す必要があります (図2)。その際、人間の力に頼らざるを得ないですが、自動翻訳機AIを利用することで自動化出来る部分もあります。(本ブログの最後に補足として後述しています。)

図2 キーワードマッチング程度の軽いフィルタだけでは、学習データとしてふさわしくないノイズが多く紛れている。したがって、その後、人 or システム (翻訳機の利用、学習データ作成に特化したAI等)により、対象Classにふさわしいデータを選別する必要がある。

対象Classにふさわしいデータも、ふさわしくないデータも、共に大事

Classにふさわしいデータの選別が終了後、本来の目的通り 味としての辛い と言及しているデータだけを対象Classに追加し、対象Classにふさわしくないデータは廃棄して学習に進んでしまいがちです。しかし、この手法をとってしまうと、AIは 味としての辛いと言及していないデータも 味としての辛い言及したと間違った予想をしてしまいます(図3)。

図3 対象Classにふさわしいデータだけを追加してしまうと偽陽性の予想が増えてしまう。

この問題はAIに対象Classに入るべきデータだけを教えて、入るべきではないデータを教えなかったことが原因です。したがって、この問題を解決するためには、対象にふさわしいデータだけではなく、ふさわしくないと選別されたデータの両方を学習データに加える必要があります (図4)。

図4 データを加えるときの注意点。『辛い』を追加する場合、『味としての辛い』と言及しているものを対象Classに追加し、『つらいの意味で使われている辛い』は不適切であるため廃棄したいと考えがちです。しかし、そのように排除し学習してしまうと、『つらいの意味で使われている辛い』は不適切とAIが理解できないため、つらいの意味で使われていても『辛い(からい)』のClassに予想されてしまいます。したがって、『つらいの意味の辛い』等を他のClassに学習データとして含ませることで、それらが『辛さ(からさ)に関する言及をしている』Classからそれらが排除されることを教えて上げる必要があります。

そのためには、対象Classにふさわしくないデータ全てに他のラベルを付与し、学習データに追加する必要があります。 しかし、このラベル付けの作業には多くの時間的、金銭的コストが掛かかるため、できれば避けたいです。そこで本ブログでは自動的にラベル付けをする方法を最後に紹介します。

対象Classにふさわしくないデータに再度ラベルを付ける

対象Classにふさわしくないデータ全てに、手動でラベルを付与することは非常に労力がかかります。そこで、対象Classを除いたデータで学習させたAI (図5 上)を用いて、対象Classに属さなかったデータを予想させ、それをラベルにすることで、大量のデータであってもラベルをつけることが出来ます (図5 下)。*2

図5 対象Class以外のClassで学習させたAIを用いて、仮をラベルを付ける方法

このように、対象Classにふさわしいデータだけではなく、ふさわしくないデータの両方をうまく利用することでAIの精度向上を達成することができます

まとめ

AIをビジネス運用していると、とあるClassの分類精度が他のClassより大事な状況や、環境の変化でAIに求められる性能が変化することは多々あります。その要求にAIがうまく対応するために、とあるClassだけ特別にデータを作成したいと思うことはAIエンジニアにとって比較的あるあるなのではないでしょうか?

この時、対象ClassにふさわしいデータだけをそのClass に追加する方法を思いつきがちですが、この方法では精度が上昇しないどころか、下がる場合もあります。実際、筆者も色んな人の協力や、システムを利用して作成したデータを追加したのに、AIの精度が上昇しなくて苦労した経験があります。その大きな原因は、対象Classにふさわしくないとされたデータを破棄したことにありました。したがって、AIの精度向上のためには、本ブログで紹介したように、対象Classにふさわしくないが対象クラスに近いデータを他のClassに追加して、そのクラスに入るべきではないデータも同時に教える必要があります。

また、筆者がこのブログでもう一つ伝えたかったこととして、アノテーションを手動ですべて行うことは無理があるため、なるべく簡素化するための方法を考える必要があるということです。

本ブログでは、筆者が行った工夫の例として、

を紹介しました。その他にも、MLM (Masked Langage Model)を用いた同音異義語の分類等、様々な工夫も考えられます。

読者の皆さまが同様な状況になった時、この情報が役立つことを願っています。また、本ブログで紹介したもの以外の原因や工夫も考えられますので、なにか思いつくことがあれば私のtwitterにお知らせください。

※ このドキュメントは文書分類の話ですが、画像分類を含めたその他の種類についての同様に適応できる考えです。

補足

本ブログの補足として、データのラベル付けをある程度自動化するためのテクニックを紹介します。

補足① : 翻訳機を利用して、同音異義語を区別する

機械翻訳では文脈まで考慮されるので、日本語の同音異義語も翻訳すると、他の単語に変換されることを利用出来ることもあります。

例として、味として適切なデータである

このスパイス辛いからホント好き

を英訳すると (DeepLを使用)

I really like this spice because it's hot.

のように辛い(からい)は hotspicy に変換されます

一方、味として適切でない

ここ最近、本当に辛いから運動して気分転換しよ

を英訳すると (DeepLを使用)

It's been really hard here lately, so I need to get some exercise and change things up.

となり、 辛い(つらい)は hard に変換されます

このように、機械翻訳をし、日本語の同音異義語を別の単語にすることで意味によるフィルタリングができることがあります。

一方で、この手法では一定確率でノイズ(ラベルの誤り)が発生してしまうリスクもあります。ここで発生するノイズの割合が低ければ、AIを用いたデータのクレンジングの利用も筋が良いと思います。

補足② : 学習データを作成するためだけのAIを構築する

人や翻訳機で全てのデータをアノテーションするのは、時間やお金のコストがかかってしまいますが、ある程度のデータだけは人力で溜めて、 味としての辛いデータ or not を診断するだけのAIを新たに開発することも考えられます。 このAIが一定水準以上の精度を得られることが担保できれば、学習データのアノテーションを半自動 & 高速に作成することも出来ます。

※注意 : ここで、ふさわしいデータ or not を診断できるAIを作成できるなら、これをサービスで利用すればいいのでは?と思うかもですが、このAIは簡単なフィルタリングを通した後のデータに対しての分類AIなので、汎化性能は少なくフリーテキストのデータの推論には向いていません。

*1:色々とフィルター条件を調整することで適切なフィルタを作成可能かもしれませんが、ルールでの対策はいたちごっこになりがちです。そして、その条件を見つけるには多くの時間と労力がかかります。というより、そもそもそのフィルタが完成するのなら、それをサービスで利用すればよく、AIを構築する必要はありません

*2:ここでのラベルはAIがつけた仮のラベルであるため学習は完全な教師あり学習ではなく半教師あり学習になります。

AWS Lambdaの構成管理のためにterraformを導入してみた

FASTALERT開発チームバックエンドエンジニアの鈴木(泰)です。

本記事は、AWS Lambdaの構成管理のためにTerraformを導入してみたというお話です。

TL;DR

  • FASTALERTチームの開発文化とTerraformの導入に至った背景
  • AWS Lambda関数をTerraformでどう管理しているか

もくじ

本対応の背景

具体的なAWS Lambda x Terraformの話に入る前に、FASTALERTチーム、workerレポジトリ、今回Terraformの導入に至った動機について、少しだけお話しさせてください。

FASTALERTとチーム文化

FASTALERTは、事件・事故・災害などのリスク情報をAIが自動収集し、収集された情報をお客様にとって使いやすい形で提供するBtoB向けのSaaSです。まだまだ成長過程のサービスであるため、顧客からのフィードバックを含め、寄せられる要望へ迅速に対応しなければなりません。

上記のような事情から、FASTALERTのバックグラウンド処理の内容は規模が小さく多岐に渡り(広く浅い)、「開発速度」が重要となってきます。

様々なバックグラウンド処理

  • データやシステムの監視とメトリクス算出、データベース中の防災情報の管理、防災情報へのメタ情報、社内システム連携
  • トリガーも様々。イベント駆動、Web API、運用者が不定期的に手動で実行する、等。

開発速度を重要視するチームの慣習

  • ほぼ全てのコンポネントがCD化されている、本番稼働するシステムの全ソースコードはGitで管理されている、Gitのタグを付与するだけのお手軽リリース、IaCの導入、スクラム開発

workerレポジトリ

FASTALERTチームにはworkerという名前のレポジトリがあります。 このレポジトリは、様々なバックグラウンド処理を小さい工数で提供し、かつ、ソースコード管理システムでソースコードを管理し、CI/CDを完備することを目的とし、Lambda製の様々なバックグラウンド処理を一元的に管理しており、Lambda関数を簡単に追加できるようにしています。具体的には(1)ソースコードを書く(2)デプロイ用のコードを追加(3)Gitのタグを付与する。これだけで新しいLambda関数をリリース可能です。本記事執筆時においては約50個のLambda関数が管理されており、ソースコードの追加・更新が頻繁に行なわれています。

apexのメンテナンス停止

workerレポジトリでは、Lambda関数の構成管理ツールとしてこれまではapexを利用していました。apexが2019年にメンテナンスを停止しました。今回のTerraform導入は、このapexのEOLを受けてのことです。

Lambdaの構成管理ツールとしてTerraformを採用した理由

Terraform以外の構成管理ツールの候補としてAWS SAMがありました。Terraformを採用することとなった決め手となったのは、以下の点です。

  • AWS上の実際のリソース設定とTerraformのstateを柔軟に解消できる。
  • FASTALERT開発チームのメンバーはTerraformを使い慣れている。

AWS上の実際のリソース設定とTerraformのstateを柔軟に解消できる

過去の経験上、SAMはリソース定義(yamlファイル)と実際のインフラ設定の間に生じた差分の解決がとても面倒臭い、という印象があります(昔、SAMを利用していたとき、差分が生じて、CloudFormationが動かなくなり、CloudFormationに紐づくリソースを丸ごと削除して新しく作り直す、というような危険な運用をしたことが苦い経験として記憶に焼き付いていたりします)。

IaCを進める過程において、構成管理用のソースコードと実際のインフラ設定の間に差分が生まれるということはよくあります。FASTALERTチームのように開発速度が求められる現場においては、そのような差分が発生する可能性はとても高いです。

Terraformは、CloudFormationを使用しておらず、かつ、実際のインフラ設定との差分を解消するためのimportコマンドがあるため、差分の発生に対して柔軟に対処することができます。

FASTALERT開発チームのメンバーはTerraformを使い慣れている

Terraformを採用したもう1つの理由は、FASTALERT開発チームメンバーがTerraformに慣れているという点です。最近FASTALERTチームでは、構成管理をTerraform化することを推奨しており、Terraformを使えるメンバーが増えつつあります。

AWS Lambdaの構成管理のためにTerraformを導入(詳細)

それでは、我々がどのようにしてTerraform化を実現したかについてお伝えいたします。

本記事で紹介している詳細実装は、Python3.8、Terraform1.1.7で動作することを前提としています。

コードサンプルはこちらにあります。

ディレクトリ構造

workerレポジトリは以下のようなディレクトリ構造です。

functions/
terraform/
build-pkg.sh
Pipfile
Pipfile.lock

Pipfile, Pipfile.lockは依存ライブラリを管理します。

本記事の要となる、functions/, terraform/ディレクトリについての詳細です。build-pkg.shはLambda用のzipを作るためのスクリプトです(後述)。

# functionsディレクトリ配下にはLambda関数のエントリーポイント群があります。
functions/
functions/lambda_process_checker.py
functions/lambda_export_api_log.py
...以下省略...

# 複数のLambda関数から共通して使われるソースコードは
# functions/worker_lib/ ディレクトリ配下にあります
functions/worker_lib/
functions/worker_lib/common.py
...以下省略...

# Terraformディレクトリ配下にはTerraformのコードがあります。
terraform/

## 共通するリソース(IAMとか)はmain.tfに書いています。
terraform/main.tf

## Lambda関数のリソース定義は
## 可読性を向上させるために
## 基本的にはLambda関数1つにつき、1つのファイルです。
## ファイル名は /functions ディレクトリ配下のpythonのソースコードと同じ。
terraform/lambda_process_checker.tf
terraform/lambda_export_api_log.tf
...以下省略...

ファイルの中身

functions/process_checker/main.pyの中には、Lambdaのエントリーポイントがあります。

# coding: utf-8
def handler(event, context):
    print('start process_checker')
    ...以下省略...

terraform/main.tfの中には、共通するリソース定義があります。

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_iam_role" "worker-role" {
  name        = "worker-role"
  description = "Do not edit manually! This is auto generated by Terraform. Allows Lambda Function to call AWS services on your behalf."
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      ...以下省略...

terraform/lambda_process_checker.tfの中には、Lambda関数リソースの定義があります。dists.zipは、build-pkg.sh(後述)が生成するLambda関数のパッケージです。

resource "aws_lambda_function" "aws_lambda_process_checker" {
  function_name    = "aws_lambda_process_checker"
  filename         = "dists.zip"
  source_code_hash = filebase64sha256("dists.zip")
  runtime          = "python3.8"
  role             = aws_iam_role.worker-role.arn
  handler          = "lambda_process_checker.handler"
}

Lambdaのデプロイ方法

  1. Lambda関数のパッケージを作る。
  2. terraform applyコマンドを実行する。

Lambda関数のパッケージを作る

Lambda関数用のzipファイルを作ります。全てのLambda関数のエントリーポイント、その実行に必要となる依存ライブラリを全て1つのzipファイルの中に格納します。参考 .zip ファイルアーカイブで Python Lambda 関数をデプロイする

workerレポジトリ上のbuild-pkg.shがzipファイルを作ります。build-pkg.shが正常終了するとdists.zipというファイルが生成されます。

#!/bin/bash

set -e

WORK_DIR="/tmp/lambda-pkg-$(date +%s)"
DIST_DIR="${WORK_DIR}/dists"
OUT_DIR=$(pwd)/terraform

mkdir $WORK_DIR
mkdir $DIST_DIR

pipenv lock -r > requirements.txt
pip install -r requirements.txt -t ${DIST_DIR}/
cp -r ./functions/* ${DIST_DIR}/

cd ${DIST_DIR}/ && zip -q -r dists.zip * && mv dists.zip ${OUT_DIR}/

注意点

この実装方法ですと、Lambda関数の数が増えていくのに伴い、dists.zipファイルのサイズが大きくなることが懸念されます。dists.zipファイルのサイズが大きくなることを回避するための実装方針として、サイズが大きくなりがちな変数の定義(長い文字列や配列、連想配列の定数値)はDynamoDBやS3等の永続化層へ配置するようにしています。Pythonのソースコードが増えるだけであれば、ファイルのサイズが大きくなることは回避できます。(それでも、Pythonの依存パッケージのサイズが大きくなってしまう懸念点は依然として残されています。。。もしそうなってしまった場合、AWS Lambda Layers等の利用を検討すべきかもしれません。)

terraform applyコマンドを実行する

dists.zipが生成された後、以下のコマンドを実行し、Lambda関数をデプロイします。

cd terraform && terraform init && terraform apply

以上です。

新しいLambda関数を作成する場合、Pythonのソースコード、Terraformの定義ファイルを追加するだけです。とてもお手軽に作れます。例えば、new_func1という新しいLambdaを追加する場合、以下の2つを追加するだけです。逆に既存のLambdaを削除する場合、2つのファイルを削除するだけです。

  • functions/new_func1/main.py
  • terraform/lambda_new_func1.tf

コードサンプルはこちらにあります。

所感

AWS Lambdaの構成管理ツールとしてTerraformを導入してから3ヶ月ほど経っています。本対応を終えて、振り返って思うことをお話しします。

本記事で紹介した実装方法のメリットは、シンプルな構成であることかなと思います。ソースコードとインフラのリソース定義が1つのレポジトリで完結し、全体の見通しが良いです。Lambdaの追加や削除がとても簡単にできます。

実務的に困るようなデメリットは今のところは顕在化していません。しかしながら本記事で紹介した、規模の小さなプログラムを一元管理するようなタイプのレポジトリ(上記のworkerレポジトリ)の注意点として以下のことがあげられます。本来マイクロサービスとして独立して作るべき処理を、処理の追加とリリースが簡単という理由だけで、追加してはならないということです。このタイプのレポジトリに追加して良い処理は、緊急性が必要とされ、かつ、マイクロサービスとして切り出すことの費用対効果が得られないもの(規模の小さな処理、マイクロサービスとして切り出すべきかどうか、その境界が曖昧な処理)、に限定すべきです。

あとがき

本記事では書いていないこと

  • 実際には、zipファイルの作成、terraform applyコマンドの実行は、CD環境上で自動化されています。
  • Pythonソースコードのユニットテストの実行は、CI環境上で自動化されています。
  • 我々の本番環境では、workerレポジトリ上で管理されているLambda関数は約50個あるため、functions/ディレクトリ配下には50個のLambdaのエントリーポイント、terraform/ディレクトリ配下には50個のLambda関数リソース定義があります。
  • 実際には、Terraformのリソース定義ファイルはもう少し作り込んであります(Lambdaのトリガーリソースの定義、モジュール機能、-var-fileオプション、stateファイル管理周り)。今回の記事では省略しました。
  • 50個全てのLambdaを、無停止でapexからTerraformへ切り替える作業が本当に大変なところであり、話したいところでもあるのですが(汗)、記事がとても長くなってしまうので省略しました。

Hydraで書かれたコードをVertex AIでハイパーパラメータ調整できるようにした



初めまして、JX通信社のMLチームでインターンをしている田中です。これまで物体検知や深層距離学習を使った画像分類、自然言語の分類や生成などのAI作成、MLOpsなどをやっています。今回、MLエンジニアのヨンテさんのもとで文書生成AIのハイパーパラメータ調整の並列化に挑戦しました。

Hydraで書かれたコードをVertex AIでハイパーパラメータ調整するためのサンプルコードはgithubにて公開しています! ぜひ、一度試してみてください!😊

github.com

背景

JX通信社のMLチームでは「力を使うべき場所に注力しよう」の理念のもとに、機械学習用のテンプレートコードを作成しており、その中ではPyTorch LightningやHydraなどが使用されています。

理念やテンプレートコードについてはヨンテさんが書いた解説記事があるので読んでみてください。

tech.jxpress.net tech.jxpress.net

文書生成AIの学習は、1回の学習に数時間程度かかります。

ハイパーパラメータ調整の学習を一つのマシーンで直列で回していると、図1の上の例ように1回の学習時間×学習の試行回数以上の時間がかかり、数日以上かかってしまいます。

図1 ハイパーパラメータ調整時に一つのマシーンで学習した場合 (上の例)と、複数マシーンを並行して学習したとき(下の例)の完了までにかかる時間 の比較。Vertex AI hyperparameter tuningは学習を並行化できるだけではなく、並行学習を直列につなげることで、ベイズ最適化も実施することができ、最適なパラメーターをコスパよく高精度に探索することができます。*1

JX通信社で大事にしているバリューの一つにSpeedがあり、その理念をML側からも支えるために、学習をより早く終わらせ、事業の開発スピードを早めることを目指しています。

そこで、ハイパーパラメータ調整の際は、たくさんの実験を並列で回したいです 。このような並列学習は、フルマネージドMLサービスであるVertex AIを使用すれば簡単に実現できます(図1下の例)。

一方で、Vertex AI Trainingのハイパーパラメータ調整機能と、JX通信社のテンプレートコードで用いられているHydraの相性が悪く、そのまま使えなかったので、工夫して動くようにしました!

問題点

ハイパーパラメータ調整はVertex AI側がコマンドライン引数を用いてハイパーパラメータを学習コンテナに渡し学習が行われ、学習が終わった後に最適化の評価値を専用のライブラリを使用しVertex AIに伝えることで、ハイパーパラメータの最適化が行われます。

しかし、以下のようにVertex AIが渡すコマンドライン引数の形式とHydraが対応するコマンドライン引数の形式が違うためうまくできませんでした。

Vertex AIの公式ドキュメントに記載されている形式

argparse を推奨

python3 -m my_trainer --learning_rate learning-rate-in-this-trial

⚠️ 注意

自分が調査した限り、実際渡されている形式は上の形式ではなく

--learning_rate=learning-rate-in-this-trial

でした。

推奨されている argparseだとこの形式でも受け取れます。

Hydraの形式

python3 my_trainer.py learning_rate=learning-rate-in-this-trial

解決方法

解決方法はいくつか考えられます。

  1. Vertex AIがHydra形式に対応するのを待つ

    → 一番良いが、対応されるか分からない

  2. Vertex AIのハイパーパラメータ調整機能を使わず、オープンソースのハイパーパラメータ自動最適化フレームワークOptunaを使用する

    → 並列ではなく直列でHydra+Optunaで使用しており、一つのインスタンスで完結するため、さまざまな環境で動かすことができるので便利。しかし、同一マシーンではなく並列で回す際はSQLのサーバーを立てる必要があり大変。(Optunaの並列について)

  3. argparse など対応しているコマンドライン引数解析ライブラリを使う

    → 推奨されているが、パーサーの定義が大変、設定ファイルを使用できない

  4. Vertex AIが渡す引数の形式を変換する処理を挟んで実行する

    → 強引だが、導入コストが一番低い

今回は導入コストが一番低い4を採用しました。

Hydra形式へ変換し、本来の学習コードを実行するスクリプトの作成

まずは、Vertex AIからコンテナに渡される入力(コマンドライン引数)の変換を考えます。今回、コマンドライン引数の変換にはshell scriptを使いました。

shell scriptでのコマンドライン引数の受け取りですが、一番目の引数が欲しい時は $1 で取り、n番目は $nで取れます。今回は引数の個数は可変なため $@で引数全体をそのまま文字列として受け取ります。

次に受け取った文字列を正規表現で置換を行いHydra形式に変換します。

shell scriptなのでsedコマンドを使用して正規表現の置換を行います。

以下のようなコマンドにすることで、引数の形式については

--key=value--key value 両方とも受け取れるようにします。

sed -r 's/--([^= ]*)[= ]([^ ]*)/\1=\2/g'

sedコマンドはVimの置換と同様に /で区切りs/正規表現/置換/フラグと言う形式で(expr)が \1, \2と対応しています。そして最後の gは複数回置換するフラグです。また、sedコマンドは最短一致に対応しておらず、マッチしたいものの後ろにある文字以外を繰り返すという処理で対処しています。( [^ ]* のところなど)

  • 実行例
❯ echo "--lr=0.001 --batch-size 64" | sed -r 's/--([^= ]*)[= ]([^ ]*)/\1=\2/g'

lr=0.001 batch-size=64

最後に変換した引数を使って本来の学習コードを実行できるようにします。

$(expr)はexprを実行した結果を文字列として受け取れるので、それを用いて

python train.py $(echo $@ | sed -r 's/--([^= ]*)[= ]([^ ]*)/\1=\2/g') # train.pyはHydraを用いたコード

とすると、変換した引数を本来の学習コードの実行コマンドの後ろに追加できます。

あとはこれを train.shなどでファイルを作成し、Vertex AIの設定ファイルの実行コマンドに ./train.shなどと記載すれば完成です。設定ファイルの実行コマンドの記載場所は HyperparameterTuningJob.trialJobSpec.workerPoolSpec.containerSpec.commandです(参考)。

HydraのコードでVertex AIのハイパーパラメータ調整ができた

このような工夫を行った結果、Vertex AIでハイパーパラメータ調整を並列に計算することができました(図2~4)。図3,4には、複数の色の線が同時に存在しており、これは複数インスタンスが並行して学習していることを意味してます。

これにより、これまで数日かかってしまう学習も、数時間で終えることができました!

図2 ハイパーパラメータ調整の各トライアルと、そのトライアルで利用されたハイパーパラメータの値。具体的な変数名は隠してます。

図3 Vertex AIのハイパーパラメータ調整時のCPU使用率の遷移。それぞれの線の色は各トライアルに対応している。

図4 Vertex AIのハイパーパラメータ調整時のGPU使用率の遷移。それぞれの線の色は各トライアルに対応している。

まとめ

Vertex AIのハイパーパラメータ調整ジョブの時、スクリプトを挟み正規表現でパラメータの形式を変換することで、Vertex AIから渡されるハイパーパラメータをHydraが受け取れる形式に変換することができました。

これにより、Hydraを使用した既存のコードを使用しながら並列でハイパーパラメータ調整できるようになり、学習時間の短縮ができました。 今回の内容について、sample codeをgithubに公開しています。このコードが皆さまの役に立てれば幸いです。

今回の方法以外により良い方法があるかもしれません。私自身まだまだ、勉強中なので改善点などアドバイス頂けられたら幸いです。

補足:チームでR&Dに取り組む工夫

このブログでご紹介しましたが、JX通信社ではPytorch Lightningをベースとした、テンプレートコードを用いて学習を行うことで、チームで効率よく実験ができるようにしています。

興味があれば以下のブログも読んでみてください

tech.jxpress.net tech.jxpress.net

我々とともに挑戦する仲間を求めています

我々とともに成長しながら、より良い社会のためのMLを開発したい仲間を社員・インターン問わず積極的に募集しています!また、MLエンジニアはもちろん、あらゆる職種のエンジニアを求めています!

正社員、インターン、そして副業・復業として体験的に働くことで、当社のカルチャーや働き方等を知っていただいたうえで正式入社を見極めていただくことが可能な「おためし入社」制度などもあります!ほんの少しでも興味を持たれた方はこちらを覗いてみてください!


*1:⚠️ Vertex AIを利用するさらなる利点

ハイパーパラメータ調整をする方法は主に以下の2つに分けられます。

①実験前に予め、ハイパーパラメータを決めて実験を行い、その中で最も性能の良いパラメータを採用する方法 (Grid searchなどが有名)

②とあるハイパーパラメータを与え実験し、その結果を元に更に次のハイパーパラメータ決めて実験を行い、を繰り返すことで最適なハイパーパラメータを探索する方法 (ベイズ推定などが有名)

①の場合は探索するハイパーパラメータは実験前から決まっているため、すべての実験を一度に並行で回せばよく、図1の下の例の1回目の実験で終わります。しかし、場当たり的にハイパーパラメータを選んでいるため、無駄な計算も多くなってしまい、最適なハイパーパラメータを得るためには膨大なコストがかかる可能性があります。

一方、②の方法では、戦略的にハイパーパラメータを選んでいるため最適なパラメーターをコスパよく高精度に探索することができます。しかし、前の学習結果に依存して次の実験で探索するハイパーパラメータが決まるため、全ての学習を一度に並行で回す事はできません。

並行学習を直列でつなぐことができれば、並行学習とベイス最適化の両方の恩恵を受けることができますが、一般的には実装が非常に大変です。しかし、Vertex AIを利用することで並行学習の直列化をほとんど苦労することなく実施することができます (図1 下の例)。