BigQuery MLでTensorFlowのモデルを呼び出す

Qiitaには初投稿です。
今回は、「BigQuery MLからTensorFlowのモデルを呼び出してみた！」というやってみた系の記事を書いてみます。

## そもそもBigQuery MLとは？

* BigQuery(以下BQ)はGoogle謹製のデータウェアハウス。大量のデータを短時間で捌けることが魅力
* BigQuery ML(以下BQML)はそれに機械学習の機能をのっけたもの。なんとSQLだけで機械学習ができる。
* BQMLはGoogle Cloud Next 2018で発表された。当初、利用できるアルゴリズムは「線形回帰」と「ロジスティック回帰」に限定
* Google Cloud Next 2019(筆者もSF現地に行きました)で、BQMLの新機能が発表される
* そのうちの1つがTensorFlow modelをimportできるようになること。発表ではアルファ版と発表されていたがいつの間にかベータ版になっていた

## 大体の流れ

公式ドキュメントは[こちら](https://cloud.google.com/bigquery-ml/docs/reference/standard-sql/bigqueryml-syntax-create-tensorflow)。
この記事の執筆当時(2019/07/29)には日本語はまだありませんでした。
ドキュメントに情報量はさほど多くないですが、ざっと以下のような流れでBQMLからTFモデルを呼び出せるようです。

* TensorFlowのModelを何かしらの環境(私はGoogle Colabをよく使います)でつくる
* Google Cloud Storage(以下GCS)上に、SavedModel形式で保存したTensorFlowのモデルを置く
* TensorFlowを呼び出すモデルをSQLで作成する
* BQに画像情報のテーブルを作成する
* SQLでTensorFlowモデルを呼び出す旨を記述

とっても簡単ですね！
…と思っていたらそこそこ詰まったので、メモとして残しておきます。

※なお、BQMLの他のアルゴリズムとは異なり、SQLでTensorFlowモデルを作成/訓練できるわけではないです。TensorFlowモデルを関数として利用できるというイメージ。

## TensorFlowモデルの作成

* ここでは簡単な画像分類モデルを作成
* [Food-101](https://www.vision.ee.ethz.ch/datasets_extra/food-101/
)データセットから「ドーナツ」「餃子」「ホットドッグ」「ピザ」「ラーメン」「寿司」の6種類をピックアップ(そのとき食べたかったもの)
* クラスごとに1000枚あるデータを、train/val/test=800/195/5に分割(testは今回は疎通確認に使うだけなのでオマケ)
* imagenetで訓練されたMobileNetV2をFineTuningしてモデルを作成
* ``tensorflow.keras``が利用できる(KerasユーザーでナマのTensorFlowに慣れていなくても大丈夫)
* ここはBQMLとあまり関係ないので、MobileNetV2についてとかFineTuningの意味とかは省略


```python
import numpy as np
import tensorflow as tf
import tensorflow.keras.backend as K
from tensorflow.keras.applications import MobileNetV2
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense, Lambda
from tensorflow.keras.optimizers import SGD
from tensorflow.keras.preprocessing.image import ImageDataGenerator
```
Kerasを直でimportするのではなく、``tensorflow.keras``にしましょう。基本的には代替できるはず。

```python

classes = ['donuts', 'gyoza', 'hot_dog', 'pizza', 'ramen', 'sushi']
img_height = 224
img_width = 224

mob = MobileNetV2(include_top=False, weights='imagenet', input_shape=(img_width, img_height, 3))
X = mob.output
X = GlobalAveragePooling2D()(X)
out = Dense(len(classes), name='prediction', activation='softmax')(X)

finetune_model = Model(inputs=mob.input, outputs=out)
for layer in finetune_model.layers[:-13]:
  layer.trainable = False
```
訓練済みモデルをピョロッと引っ張ってこられるのはKerasの良いところですね。
-13が示すのは最後のblockだけFineTuningするよという意味です。気になったらsummaryでも叩いてみてください。

```python

train_datagen = ImageDataGenerator(rescale=1./255,
                                  zoom_range=[0.8, 1.2],
                                  rotation_range=20,
                                  width_shift_range=0.1,
                                  height_shift_range=0.1,
                                  horizontal_flip=True,
                                  fill_mode='nearest'
                                  )
train_generator = train_datagen.flow_from_directory('dir_train',
                                                    target_size=(img_height, img_width),
                                                    batch_size=32,
                                                    class_mode='categorical'
                                                   )
val_datagen = ImageDataGenerator(rescale=1./255)
val_generator = val_datagen.flow_from_directory('dir_val',
                                               target_size=(img_height, img_width),
                                               batch_size=32,
                                               class_mode='categorical'
                                               )

finetune_model.compile(loss='categorical_crossentropy',
              optimizer=SGD(lr=0.001),
              metrics=['accuracy']
              )
finetune_model.fit_generator(train_generator,
                             epochs=10,
                             validation_data=val_generator
                             )
```
適当にData Augmentationして実行しましょう。精度は今回は二の次なので、10epochsだけ回します。
ColabのGPUを利用して、1epochあたり80秒前後でした。軽いモデルはいいですね。
なお詳細は省略しますが、val_accは0.8540と想定してたより高く出ました。

さて、ここからが今回のために必要なところです。
BQには画像データをカラムにそのまま突っ込むみたいなことはできなくて、base64の文字列で代替します。
すなわち、保存するモデルのinputはstring型である必要があります。
現在のモデルのinputは``(None, 224, 224, 3)``のshapeをもったテンソルなので、stringをそのテンソルに変換する処理を書きましょう。

```python

def preprocess_and_decode(img_str, new_shape=[224, 224]):
  img = tf.io.decode_base64(img_str)
  img = tf.image.decode_jpeg(img, channels=3)
  img = tf.image.resize_images(img, new_shape)
  return img

transform_input = Input(shape=(1,), dtype='string')
transform_output = Lambda(lambda img: tf.map_fn(lambda im: preprocess_and_decode(im[0]), img, dtype='float32'))(transform_input)
transform_model = Model(transform_input, transform_output)

serve_input = transform_model.input
serve_x = finetune_model(transform_model.output)
serve_output = K.argmax(serve_x)
serve_model = Model(serve_input, serve_output)
```
Lambdaをゴチャゴチャと使うことで、tensorflowの関数のみでデータ変換を実現することができます。
最後にこれらのモデルとガッチャンコしてあげることで、最終的なモデルができあがります。
なお、MobileNetV2の出力はクラスそれぞれのスコアで、ここでは要素数が6(クラスの数)のベクトルです。
ここでは一番確率が高いクラスを出力したかったので、argmaxをとってそのインデックスを出力しています。
(インデックスはクラス名の辞書順でつけられるので、ここではdonuts, gyoza, hot_dog, pizza, ramen, sushiの順になります。
本当はクラス名を直接出力したかったのですがいい方法が思い浮かびませんでした)
それでは、このモデルをSavedModel形式で保存しましょう。


```python

with K.get_session() as sess:
  tf.saved_model.simple_save(
      sess,
      'saved_model',
      inputs={'inputs': serve_model.input},
      outputs={'outputs': serve_model.output}
  )
```
Kerasマンからするとセッションとは何ぞやとなりがちですが、``simple_save``を利用することで簡単にモデルを保存できます。
saved_modelフォルダにsaved_model.pb(グラフのメタ情報)とvariablesフォルダ(重み/バイアスとかの情報)が作成されるので、フォルダごとまるっと保存しましょう。
これでモデルの作成は終了です。
(特にモデルをくっつけるあたり、稚拙なコードになってしまったなあとも思うので、「こういう書き方のほうがいいよ」みたいな指摘があれば是非。)

## GCSにTensorFlowのモデルをアップロード

やるだけです。コンソールからでも不足ないです。
リージョンはとりあえずus-east1で動かしましたが、そういえば東京では調べて/試していません。すみません。

## BQ上でモデルの作成

BQMLでは一般に、``CREATE MODEL``句でモデルを作成し、``ML.PREDICT``句で予測します。
TensorFlowのモデルを呼び出す場合も同様ですが、``CREATE MODEL``句でモデルそのものを作成するわけではありません。
``ML.PREDICT``を記述するためのモデルの箱を作ってあげるという感じですかね。
まずデータセットを作成し、データセット下で以下のようなクエリを送信しましょう。

```sql

CREATE MODEL your_dataset_name.your_model_name
 OPTIONS(MODEL_TYPE='TENSORFLOW',
         MODEL_PATH="gs://path/to/saved_model/*")
```

無論データセット名、保存するモデル名、GCS上のパスなんかは各自で変えてください。(パスの*はそのまま残しましょう)
ちゃんとモデルが生成できたらSavedModelがうまく機能しているといえるでしょう。

## BQに画像情報のテーブルを作成する

ある種当然ですが、BQMLにおける予測は基本的にBQ上のデータに対して行われます。
それでは、クエリの対象となるデータを作成しましょう。
csvを作成し、それをソースにテーブルを作るのが(小規模であれば)個人的には楽かなと思います。

```python

labels = ['donuts', 'gyoza', 'hot_dog', 'pizza', 'ramen', 'sushi']
img_info_list = []
for label in labels:
    img_path_list = os.listdir(os.path.join('dir_test', label))
    for img_path in img_path_list:
        img_info = [img_path, label]
        with open(os.path.join(base_dir, label, img_path), 'rb') as f:
            data = f.read()
            encode = base64.urlsafe_b64encode(data)
            img_info.append(str(encode)[2:-1])
        img_info_list.append(img_info)

output_df = pd.DataFrame(img_info_list, columns=['image_name', 'label', 'image_encoded'])
```
コードはだいぶ適当ですが、要は画像をbase64形式に変換してcsvに突っ込めばOKです。
どうやらurlsafeのbase64を使う必要があるようです。(普通のbase64から+を-に、/を_に変えたものと等しいです)
あとはエンコードした生の文字列はbyte列用に``b'xxxxxxx...'``となっているはずなので、csvに文字列として突っ込む上ではbと'を省くのがよさそうです。[2:-1]をつけているのはそれが理由ですが、もうちょっとエレガントな方法もありそうですね。
csvができたらBQ上にimportしてテーブルを作成しましょう。


##SQLでTensorFlowモデルを呼び出す

いよいよ予測です。
(ファイル名, 実ラベル, 予測ラベル)という出力をしたかったので、こんなクエリにしてみました。

```sql

WITH 
 table_0 AS (
  SELECT
   ['donuts', 'gyoza', 'hot_dog', 'pizza', 'ramen', 'sushi'] AS labels
  ),
 table_1 AS (
  SELECT 
   image_name,
   outputs
  FROM
   ML.PREDICT(MODEL your_dataset_name.your_model_name,
      (
       SELECT
        image_name,
        image_encoded AS inputs
       FROM
        `your_dataset_name.your_table_name`
      )
    )
  ),
  table_2 AS (
   SELECT
    image_name,
    label
   FROM
   `your_dataset_name.your_model_name`
  )

SELECT 
 table_1.image_name,
 label,
 labels[OFFSET(output)] AS predict_label
FROM
 table_0,
 table_1
JOIN
 table_2
ON 
 table_1.image_name = table_2.image_name
```

結果としては以下のような出力がされます。

|行|image_name|label|predict_label|
|---|---|---|---|
|1|xxxx.jpg|donuts|donuts|
|2|xxxx.jpg|donuts|donuts|
|3|xxxx.jpg|gyoza|gyoza|
|4|xxxx.jpg|gyoza|sushi|
|...|...|...|...|


私自身はSQLの達人とかではなくてクエリも稚拙ですが、BQ(というかMySQL)ではWITH句を作ると読みやすいかなと思っています。
ここで注意が必要なのは``ML.PREDICT``(table_1)における入出力で、``image_encoded AS inputs``や``SELECT outputs``という記述をしています。
この``inputs``と``outputs``はSavedModel形式で保存したときの入出力の名前(``with K.get_session() as sess:``で始まるブロック)に依存しています。ここの名前を変えた場合でもクエリのほうで調整することで実行することができました。

画像サイズによっては予測する画像数が増えるとOut Of Memoryが発生する可能性があります。
そういう場合はリサイズした画像情報を格納するとか、PREDICTブロック下にLIMIT句をおくとかで対応しましょう。

## BQMLでTensorFlowを呼ぶメリットは？
今回は接続できただけで満足してしまったので、どれぐらいスケールメリットがあるの？といったことについて調べられてなく、今後いろいろ試してみます。
しかしおそらくですが、GCS上のモデルに大量にアクセスするとそこがボトルネックになってしまい、実行速度は大して速くならないのかなと想定しています。
BQ内の計算は非常に速いので、画像のEmbeddingを出力するモデルをつくって、類似度をBQ内で計算するみたいなフローには強いのかもしれませんね。
また今回は画像で一番簡単ともいえる分類タスクにしましたが、これが物体検出やらVAEやらでも同様に実現できるのかはトライしてみます。