Firebase ML Kitで自作のカスタムモデルを使って料理・非料理画像を判定できるようにした

会員事業部の山下(@farmanlab)です。 Androidエンジニアとしてクックパッドアプリの開発を担当しています。

今回はGoogle I/O 2018で新しく発表されたML Kitをクックパッドのデータで学習したモデルを使って検証した話をします。

機械学習モデルの利用にあたって、研究開発部の菊田(@yohei_kikuta)の協力の元で検証を行いました。

これからお話する内容がイメージしやすいよう、 クックパッドの料理・非料理を判別するモデルを動かした実機デモをお見せします。

これは料理と判定された確率がfood、料理ではないと判定された確率がnon-foodというラベルのスコアで表示されているデモです。 (非)料理画像において(non-)foodのラベルのスコアが大きくなり正しく判別できていることが分かります。

f:id:farmlanlabdev:20180704182402g:plain

  • モデルは MobileNetV2
  • tensorflow-gpu==1.7.1で学習してTOCOでTensorFlow Liteのモデルを作成
  • アプリ側では入力データやモデルの重みはfloatで処理

ML Kitとは

ML Kitとはモバイルアプリ向けに機械学習機能を組み込むことができるSDKです。 Firebaseの機能の一つとして提供されており、Android/iOSに簡単に導入することができます。 大まかに以下のような特徴があります。

  • デフォルトで以下の機能が利用可能

    • 文字認識
    • 画像ラベリング
    • バーコードスキャン
    • 顔検出
    • ランドマーク認識
  • オンデバイスモードとクラウドモードで利用可能

オンデバイスモードでは端末にモデルをダウンロードすることでオフラインで動作し、無料で使うことができます。 クラウドモードはCloud Vision APIを使ってオンデバイスよりも精度の高い情報を得ることができる代わりに Firebaseの課金プランをBlazeにする必要があり、一定回数以上の利用は有料です。

  • 独自のカスタムモデルを利用可能

デフォルトで提供される機能以外に独自の機械学習モデルを利用することができます。 ちなみにカスタムモデルの利用にあたってはFirebaseの課金プランをBlazeにする必要はありません。

  • Android Neural Networks API(NNAPI)との連携

ML KitはAndroid8.1で導入されたNNAPIとの連携がSDKに含まれているため、 開発者がNNAPIに関するコードを書く必要がありません。

ML Kitとカスタムモデルを導入するまでの流れ

以下のステップでカスタムモデルをML Kitで使えるようにします

  1. アプリの依存関係にML Kitを追加
  2. TensorFlowの機械学習モデルをTensorFlow Liteのモデルに変換する
  3. FirebaseでTensorFlow Liteのモデルをホストする
  4. アプリでFirebaseのモデルをバンドルする
  5. バンドルしたモデルを利用して推論する

1と3については公式に詳しいので、説明はそちらに譲ります。 ここでは2と4と5について掘り下げていきましょう。

ML Kitはクイックスタートサンプルが用意されているので、 このサンプルに元にしつつ、自分たちのモデルを動かす上で必要になるポイントも加えて説明していきます。

2.TensorFlowの機械学習モデルをTensorFlow Liteのモデルに変換する

サンプルを動かす場合はGitHubのレポジトリmobilenet_quant_v1_224.tfliteが用意されているので、特に準備をする必要はありません。

自分のモデルを使う場合は、一言で言えばTOCOというTensorFlowのモデルからTensorFlow Liteのモデルに変換するツールを使えばいいのですが、自分で学習したモデルを使うには注意を要します。 ここではその部分を詳しく解説します。

今回はクックパッドで使われている料理・非料理判別モデルを実装します。 モデルはサンプルに倣って基本的にはMobileNetV1を使います。 冒頭で示したようにMobileNetV2でも実装ができていますが、これはV1の実装ができれば(モデルアーキテクチャ以外)全く同様にできるため、ここでは試行錯誤の過程を紹介する意味でもV1の話をします。 それぞれのモデルの詳細はこの記事では解説しませんが、学習済みのモデルはV1はこちらV2はこちらにあります。

自分たちが準備したデータでモデルを学習する部分には新しいことはなく、tensor-for-poets-2のコードtensorflow/hubのコードがそのまま使えます。 今回は料理・非料理の二値分類を対象としました。

ここで作成したTensorFlowのモデルからML Kitで適切に動作するTensorFlow Liteのモデルを作るところで苦戦したので、気をつけるべき点と共に手順を紹介します。

TensorFlow Liteモデル(.tflite)の作り方

ここでは、TensorFlowで学習して作成した xxx.pb ファイルから model.tflite ファイルへ変換することを考えます。 例えば、model_graph.pbmodel.tfliteファイルに変換するコマンドは以下のようになるイメージです。

toco \
  --input_file=/tmp/model_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/model.tflite \
  --output_format=TFLITE \
  --input_arrays=input \
  --output_arrays=final_result

このように、TensorFlowでモデルの学習をする場合はそのままTOCOを使えば.tfliteを作れるため、変換それ自体に困難はありません。 ただし、TensorFlowのバージョン依存性が強いので注意が必要です。 この記事における我々の結果は全てtensorflow-gpu==1.7.1で実行したものとなります。

他のフレームワークでモデルを学習する場合は一旦TensorFlowのモデルに変換する必要がありますが、変換用のライブラリは色々出てるので、標準的なoperationのみを使っていれば可能だと思います。 後述しますが、TensorFlow Liteではでサポートしているoperationはまだ限定的なので、特殊なoperationを含むモデルを使う場合は自分でTensorFlow Lite側の実装をする必要があります。

また、コンバーターであるTOCOは重みの量子化などのオプションも有していて、これを使ってfloatで重みを扱うモデルから量子化して扱うモデルを作ることもできます(正確には、Fake quantizationという、重みはuint8で扱うが出力はfloat32として扱う機能が提供されています)。

サンプルと同じようにやってみて上手くいかなかった話

単純に考えれば、サンプルで動いているモデルに基づき、自分たちのデータを使って再学習したモデルをTOCOを使って.tfliteファイルに変換するだけで上手くいくはずです。 ML Kitのサンプルではmobilenet_quant_v1_224.tfliteという重みが量子化されたMobileNetV1が使われているので、とりあえずMobileNetV1の量子化バージョンMobilenet_1.0_224_quantから再学習したretrained_graph.pbを使いfood-non-food.tfliteを作成します。 変換コマンドは以下のものを使用しました。

IMAGE_SIZE=224
toco \
  --allow_custom_ops \
  --input_file=/tmp/retrained_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/food-non-food.tflite \
  --output_format=TFLITE \
  --input_shapes=1,${IMAGE_SIZE},${IMAGE_SIZE},3 \
  --mean_values=128 \
  --std_values=128 \
  --inference_type=QUANTIZED_UINT8 \
  --input_arrays=input \
  --output_arrays=final_result

先程の例と比べると入力のshape指定や値の標準化なども入っています。 オプションの--allow_custom_opsに関しては、これをつけないとcustom opがないというエラーが出るのでつけています。 「それでは動かないのでは?」という自然な疑問が湧きますが、一方でサンプルで動いているモデルと同じだ(と思われる)ので動くだろうという期待もそれほど悪くないものに思えます。

しかしながら、結果はダメで、サンプルのモデルだけ置き換えると例えばDidn't find custom op for name Dequantizeなどというエラーを吐きます。 これはTensorFlow Lite側で計算グラフのoperationが実装されていないことを意味しています。 operationが無いということで、選択肢は自分で頑張って実装するかサポートされているoperationだけでモデルを作るかです。 そもそもちゃんと動くか分からない状況なので、試すまでのスピードや余計なバグの原因を混入させないという意図で、後者の方法で進めることにしました。

以降では重みをfloatで扱うモデル(以下floatモデル)をどのようにすれば動かせるかを紹介しますが、そもそもサンプルにおいて量子化されたモデルがどのように動いているかに関してはよく分かっていません。

floatモデルを動かすまでの試行錯誤

まず試したのは、floatモデルをfake quantizationして扱うという方法です。 ML Kitのサンプルが量子化されたモデルを扱っているのでこれが既存のスクリプトを書き換えずに実行する近道に思えます。 Mobilenet_1.0_224を元に再学習したretrained_graph.pbを以下のコマンドで量子化されたfood-non-food.tfliteに変換します。

IMAGE_SIZE=224
toco \
  --input_file=/tmp/retrained_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/food-non-food.tflite \
  --output_format=TFLITE \
  --input_shapes=1,${IMAGE_SIZE},${IMAGE_SIZE},3 \
  --mean_values=128 \
  --std_values=128 \
  --default_ranges_min=0 \
  --default_ranges_max=6 \
  --inference_type=QUANTIZED_UINT8 \
  --input_arrays=input \
  --output_arrays=final_result

オプションのdefault rangeがfake quantizationに必要な情報で、活性化レイヤーでの値の取りうる範囲を指定して量子化の際の情報として使います。 理想的には学習時の結果を保持して使うものですが、MobileNetV1はReLU6を使っているためこのように指定できます。

これで作ったモデルはエラーは吐きませんが、予測のスコアが[0.7,0.3]辺りをうろついてあまり変化しないという結果になりました。 この結果から推察するに入力の画像の取り扱いや重みがちゃんと入ってるかなどが怪しいところですが、いくつか調べてみても解決法は見つかりませんでした。 ML Kitの世界に行ってしまうとどこに問題があるか(モデル変換にバグがあるのかアプリ側にバグがあるのか)のデバッグが難しいということもあります。

ということで残りは素直にfloatモデルを作ってアプリ側でもfloatモデルを扱うように変更するという方法です。 モデル変換は以下で実施しました。

IMAGE_SIZE=224
toco \
  --input_file=/tmp/retrained_graph.pb \
  --input_format=TENSORFLOW_GRAPHDEF \
  --output_file=/tmp/food-non-food.tflite \
  --output_format=TFLITE \
  --input_shapes=1,${IMAGE_SIZE},${IMAGE_SIZE},3 \
  --mean_values=128 \
  --std_values=128 \
  --inference_type=FLOAT \
  --input_arrays=input \
  --output_arrays=final_result

これで得られたモデルをそのまま動かすとInput 0 should have 150528 bytes, but found 602112 bytesというエラーに遭遇します。 モデル的にはfloat32で扱うところを入力としてはuint8を想定しているために不整合が起こっているように見えます。

これは元々アプリ側では量子化されたものを扱おうとしていたのだから自然なエラーだと思われ、アプリ側を適切に変更すれば動くことが期待できます。 いずれにせよ、モデルを作る側にできるのはここまでなので、以降でこのモデルのアプリへの取り込みとアプリ側でどのように扱えば正しく動かせるのかを説明していきます。

4.アプリでFirebaseのモデルをバンドルする

では、作成したモデルをML Kitに取り込んでみましょう。 ここでの手順は他のカスタムモデルを取り込む手順と違いはありません。

ML Kitでカスタムモデルを使う際の大まかな構成は以下のようになっています。 ML Kit構成

アプリにモデルをバンドルするときにはFirebaseModelManagerを利用します。

FirebaseModelManagerに定義されている、FirebaseLocalModelSourceFirebaseCloudModelSourceのインスタンスをそれぞれ引数に取る registerLocalModelSourceregisterCloudModelSourceメソッドを使って利用するモデルのバンドルを行います。 もちろん、ローカルモデルのみ、クラウドモデルのみを利用することも可能です。

ローカルモデルの指定

val localSource = FirebaseLocalModelSource.Builder("food-non-food")
    .setAssetFilePath("food-non-food.tflite")
    .build()

Builderのコンストラクタにはモデルを識別するための任意の文字列を渡します。 Assetsフォルダ内のtffileを参照する場合にはsetAssetFilePathを、それ以外のフォルダを参照する場合はsetFilePathでファイルを指定します。

クラウドモデルの指定

val conditions = FirebaseModelDownloadConditions.Builder().requireWifi().build()
val cloudSource = FirebaseCloudModelSource.Builder("food-non-food")
    .setInitialDownloadConditions(conditions)
    .setUpdatesDownloadConditions(conditions)
    .enableModelUpdates(true)
    .build()

FirebaseModelDownloadConditionクラスでCloudモデルをダウンロードするための条件を設定することができます。 FirebaseCloudModelSource.Builderのコンストラクタにはステップ3でFirebaseにホストしたモデルの名前を指定します。 enableModelUpdatesをtrueにするとFirebaseにホストしたモデルに更新があった場合にモデルをFirebaseから更新するようになります。 この仕組みのおかげでアプリをアップデートすることなく最新の学習モデルを利用することが可能です。

モデルの登録

FirebaseModelManager.getInstance().apply {
    registerLocalModelSource(foodNonFoodLocalSource)
    registerLocalModelSource(attractivenessLocalSource)
    registerCloudModelSource(foodNonFoodCloudSource)
    registerCloudModelSource(attractivenessCloudSource)
}

FirebaseModelManagerのインスタンスを取得して、登録メソッドで渡します。 ML Kitの構成にもあるように複数の機械学習モデルを利用することも可能です。 ここでは料理・非料理の判別モデルと料理の魅力度推定モデルを登録しています。

推論モデルの指定

val options = FirebaseModelOptions.Builder()
    .setLocalModelName("food-non-food")
    .setCloudModelName("food-non-food")
    .build()
val interpreter = FirebaseModelInterpreter.getInstance(options)

FirebaseModelOptionsクラスで推定を行う機械学習モデルの指定を行います。 Firebase(Cloud|Local)ModelSource.Builderのコンストラクタに指定した名前を指定することで、 FirebaseModelManagerに登録した機械学習モデルを使用することができます。

このFirebaseModelOptionsを使って、実際に推定を行うFirebaseModelInterpreterのインスタンスを取得します。

5.モデルを使って推定する

いよいよ、カスタムモデルを使って推定を行います。

入出力のデータを指定する

FirebaseModelInputOutputOptionsを使って、学習モデルのinputとoutputのデータを指定します。

クイックスタートサンプルではbyte値を扱うコードが紹介されていますが、 今回利用する学習モデルはfloat値を扱うように作成しているので、floatの多次元配列がinputデータになります。

outputデータは画像がモデルによって予測されるカテゴリのいずれかの確率であるfloat値の多次元配列(softmaxの出力)です。 カテゴリ一覧を表すテキストファイルをassetsフォルダなどに配置して読み込みます。 今回は、料理・非料理判別モデルのカテゴリを表す

food
non-food

という内容のテキストをlabel.txtというファイル名でassetsフォルダに配置したと仮定します。

val labelList = activity.assets.open("label.txt").reader().use {
    it.readText()
}.split(System.lineSeparator())

val ioOptions = FirebaseModelInputOutputOptions.Builder()
    .setInputFormat(0, FirebaseModelDataType.FLOAT32, intArrayOf(1, 224, 224, 3))
    .setOutputFormat(0, FirebaseModelDataType.FLOAT32, intArrayOf(1, labelList.size))
    .build()

Bitmapからinputデータを作成する

まずはinputデータを格納するための多次元配列を作成します。 なお、執筆時点でのML KitはByteBufferには対応していますが、FloatBufferには対応していませんでした。

// 定数値
val IMAGE_MEAN = 128
val IMAGE_STD = 128.0f

// inputデータを格納する配列を作成
val imageData = Array(1) { Array(224) { Array(224) { FloatArray(3) } } }

val imageValues = IntArray(224 * 224)

// 224×224にリサイズしたBitmapからpixel値を取得
resizedBitmap.getPixels(imageValues, 0, resizedBitmap.width, 0, 0, resizedBitmap.width, resizedBitmap.height)
var pixel = 0
for (i in 0 until 224) {
    for (j in 0 until 224) {
       imageValues[pixel++].let {
           imageData[0][i][j][0] = (Color.red(it) - IMAGE_MEAN) / IMAGE_STD
           imageData[0][i][j][1] = (Color.green(it) - IMAGE_MEAN) / IMAGE_STD
           imageData[0][i][j][2] = (Color.blue(it) - IMAGE_MEAN) / IMAGE_STD
       }
    }
}

val inputs = FirebaseModelInputs.Builder()
            .add(imageData)
            .build()

次に学習モデルをTOCOで作成したときの IMAGE_SIZE=224 に合わせて224×224サイズにリサイズし、リサイズしたBitmapからpixel値を取り出します。

ここで重要なのが、pixelの各RGB値に対して、IMAGE_MEANとIMAGE_STDを使って演算をしている点です。 tfliteへの変換時に

toco \
  ...
  --mean_values=128 \
  --std_values=128 \
  ...

とMEANとSTDの値を指定しているので、モデル推論時にいい感じにやってくれるように思います。 しかし、実際には予め計算した値をinputとして与える必要があります。 こうして得られたinputの多次元配列データを FirebaseModelInputs.Builderaddメソッドに渡してやります。

推論結果を得る

入出力のデータを指定するで指定したoptionと、Bitmapからinputデータを作成するで得たinputデータを FirebaseModelInterpreterrunメソッドに渡すと推論が実行されます。

interpreter.run(inputs, options)
.addOnSuccessListener { outputs ->
    val result = outputs.getOutput<Array<FloatArray>>(0)[0]
    result.mapIndexed { index, value ->
        Pair(labelList[index], value)
    }
}

addOnSuccessListenerが受け取るTaskからgetOutputすることで推論結果を得ることができます。 今回はfloatのモデルを使ったのでgetOutputで得られる型はfloatの多次元配列です。 ここではlabel.txtで指定したカテゴリのインデックスと出力値をマッピングしています。

結果

実際に実機で動かしたデモをお見せします。

一つ目は冒頭でもお見せした料理・非料理判別です。 MobilenetV1とMovileNetV2の両方で実装しましたが、大きく変わるところはないので後者の結果のみを改めてお見せします。 実機デモ1

餃子やグラタンやパスタといった料理画像ではfoodのスコアが高くなり、ゴリラや紫陽花などの非料理画像ではnon-foodのスコアが高くなっていることが確認できます。

二つ目は魅力度推定です。 これは学習データとして料理の見栄えを5段階評価(数字が高いほど見栄えが良い)したものを準備し、回帰モデルを学習したものになります。 こちらはMobileNetV1のみで実装しましたのでその結果をお見せします。 実機でも2

数枚でかつ主観的な評価とはなりますが相対的に見栄えの良いと思われる画像に高いスコアが付与されており、モデルが期待通りに動いていることが確認できます。

ということで自分たちで作成した分類モデルを回帰モデルがML Kitを使って実機で動かすことができました! 今回は動かすことが目的であったため正答率や処理速度などの各種指標はまだ詳細には調べていませんが、これは単なるアプリのプロファイリングの話なので難しいところはありません。

まとめ 

今回、Google I/O 2018で発表された最新技術であるML Kitの現状をクックパッドの機械学習モデルを使って検証した話をしました。

ML Kitはまだβ機能として提供されているので、対応しているモデルのオペレータが少なかったり、 量子化されたモデルを上手く動かす情報が不足していたり、発展途上であることは確かです。 しかし、一度モデルを構築してしまえばオンデバイスで動作させることができますし、Firebase経由でモデルのアップデートも簡単にできます。

一方でオンデバイスで動作することは、常に最新のモデルを利用するようにコントロールできないということでもあるので、 設計する上で注意しなければなりません。

今後ますます機械学習を活用したサービスや事例が多く出てくると思いますし、 ML Kitは機械学習機能をモバイルに組み込むための有効な手段の一つであると感じました。

クックパッドではML Kitのような最新技術を利用したモバイルアプリ開発や研究開発がしたい!というエンジニアを募集しています。

興味がある方は採用ページ、または@farmanlab or @yohei_kikutaまで!

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/