{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Amazon SageMaker - Bring Your Own Model \n",
"## TensorFlow 編\n",
"\n",
"ここでは [TensorFlow](https://www.tensorflow.org/) のサンプルコードをAmazon SageMaker 上で実行するための移行手順について説明します。SageMaker Python SDK で TensorFlow を使うための説明は [SDK のドキュメント](https://sagemaker.readthedocs.io/en/stable/using_tf.html) にも多くの情報があります。\n",
"\n",
"注: \n",
"ここで説明するのは Script モード という記法 (現時点では標準の書き方) で、FILE モード (入力データを Amazon S3 から学習時にファイルとしてコピーする方法) です。データサイズが大きくなった場合は、FILE Mode ではなく PIPE Mode をお使い頂いた方がスループットが向上します。\n",
"また、ここでは以降手順の紹介のためトレーニングスクリプトは最小限の書き換えとしています。"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. トレーニングスクリプトの書き換え\n",
"\n",
"### 書き換えが必要な理由\n",
"Amazon SageMaker では、オブジェクトストレージ Amazon S3 をデータ保管に利用します。例えば、S3 上の学習データを指定すると、自動的に Amazon SageMaker の学習用インスタンスにデータがダウンロードされ、トレーニングスクリプトが実行されます。トレーニングスクリプトを実行した後に、指定したディレクトリにモデルを保存すると、自動的にモデルがS3にアップロードされます。\n",
"\n",
"トレーニングスクリプトを SageMaker に持ち込む場合は、以下の点を修正する必要があります。\n",
"- 学習用インスタンスにダウンロードされた学習データのロード\n",
"- 学習が完了したときのモデルの保存\n",
"\n",
"これらの修正は、トレーニングスクリプトを任意の環境に持ち込む際の修正と変わらないでしょう。例えば、自身のPCに持ち込む場合も、`/home/user/data` のようなディレクトリからデータを読み込んで、`/home/user/model` にモデルを保存したいと考えるかもしれません。同様のことを SageMaker で行う必要があります。\n",
"\n",
"### 書き換える前に保存先を決める\n",
"\n",
"このハンズオンでは、S3からダウンロードする学習データ・バリデーションデータと、S3にアップロードするモデルは、それぞれ以下のように学習用インスタンスに保存することにします。`/opt/ml/input/data/train/`といったパスに設定することは奇異に感じられるかもしれませんが、これらは環境変数から読み込んで使用することが可能なパスで、コーディングをシンプルにすることができます。[1-1. 環境変数の取得](#env)で読み込み方法を説明します。\n",
"\n",
"#### 学習データ\n",
"- 画像: `/opt/ml/input/data/train/image.npy`\n",
"- ラベル: `/opt/ml/input/data/train/label.npy`\n",
"\n",
"#### バリデーションデータ\n",
"- 画像: `/opt/ml/input/data/test/image.npy`\n",
"- ラベル: `/opt/ml/input/data/test/label.npy`\n",
"\n",
"#### モデル\n",
"`/opt/ml/model` 以下にシンボルやパラメータを保存する\n",
"\n",
"### 書き換える箇所\n",
"まず [サンプルのソースコード](https://github.com/tensorflow/tensorflow/blob/r1.14/tensorflow/examples/tutorials/layers/cnn_mnist.py) を以下のコマンドでダウンロードします。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!wget https://raw.githubusercontent.com/tensorflow/tensorflow/r1.14/tensorflow/examples/tutorials/layers/cnn_mnist.py"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"ダウンロードされた `cnn_mnist.py` をファイルブラウザから見つけて開いて下さい (JupyterLab の場合は左右にファイルを並べると作業しやすいです)。あるいはお好きなエディターをお使い頂いても結構です。この`cnn_mnist.py`は、トレーニングスクリプト内で以下の関数を呼び出し、S3以外からデータをダウンロードしています。\n",
"\n",
"```python\n",
"mnist = tf.contrib.learn.datasets.load_dataset(\"mnist\")\n",
"```\n",
"\n",
"こういった方法も可能ですが、今回はS3から学習データをダウンロードして、前述したように`/opt/ml/input/data/train/`といったパスから読み出して使います。書き換える点は主に3点です:\n",
"1. 環境変数の取得 \n",
" SageMaker では、学習データやモデルの保存先はデフォルトで指定されたパスがあり、これらを環境変数から読み込んで使用することが可能です。環境変数を読み込むことで、学習データの位置をトレーニングスクリプト内にハードコーディングする必要がありません。もちろんパスの変更は可能で、API経由で渡すこともできます。\n",
" \n",
"1. 学習データのロード \n",
" 環境変数を取得して学習データの保存先がわかれば、その保存先から学習データをロードするようにコードを書き換えましょう。\n",
"\n",
"1. 学習済みモデルの保存形式と出力先の変更 \n",
" SageMaker では TensorFlow の SavedModel 形式をサポートし、TensorFlow Serving によってデプロイします。もとの`cnn_mnist.py`では、checkpointが保存されるのみでデプロイに十分な情報がありません。SavedModel 形式で保存されるようにコードを追加します。その際、モデルの保存先を正しく指定する必要があります。学習が完了すると学習用インスタンスは削除されますので、保存先を指定のディレクトリに変更して、モデルがS3にアップロードされるようにします。"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 1-1. 環境変数の取得\n",
"\n",
"Amazon SageMaker の Script Mode では、トレーニングに用いるコードが実行時に Python スクリプトとして実行されます。その際、データ・モデルの入出力は [こちら](https://sagemaker.readthedocs.io/en/stable/using_tf.html#preparing-a-script-mode-training-script) に記述があるよう `SM_CHANNEL_XXXX` や `SM_MODEL_DIR` という環境変数を参照する必要があります。そのため、`argparse.ArgumentParser` で渡された環境変数と、スクリプト実行時のハイパーパラメータを取得します。\n",
"\n",
"\n",
"\n",
"`SM_CHANNEL_TRAIN`, `SM_CHANNEL_TEST`, `SM_MODEL_DIR` の環境変数の値を取得するよう、以下の関数をトレーニングスクリプトの最初に追加します。この関数はデフォルトでこれらの環境変数を、`args.train`、`args.test`、`args.sm_model_dir` に格納します。また、 `SM_MODEL_DIR` は `model_dir` とは異なり、`args.model_dir` には常に S3 のパスが渡されます。\n",
"\n",
"```\n",
"def parse_args():\n",
" import argparse, os\n",
" parser = argparse.ArgumentParser()\n",
" parser.add_argument('--train', type=str, default=os.environ['SM_CHANNEL_TRAIN'])\n",
" parser.add_argument('--test', type=str, default=os.environ['SM_CHANNEL_TEST'])\n",
" parser.add_argument('--model_dir', type=str)\n",
" parser.add_argument('--sm-model-dir', type=str, default=os.environ['SM_MODEL_DIR'])\n",
" parser.add_argument('--training-steps', type=int, default=20000)\n",
" args, _ = parser.parse_known_args()\n",
" return args\n",
"```\n",
"\n",
"\n",
"これらの値は、create-training-jobのAPIを実行する際に (SageMaker Python SDK で estimator を呼び出す際に) 指定した hyperparameters の値に置き換えることができます。例えば、hyperparameters に `train`、`test`、`sm-model-dir`が指定されていれば、環境変数の値は hyperparameters の値で上書きされます。ここでは、学習のステップ数 `training-steps` はデフォルトで20000という値にしておいて、学習実行時に hyperparameters 経由で変更できるようにしておきましょう。そうすることで、デバッグ時に小さい training-steps で実行したりすることができます。\n",
"\n",
"\n",
"`cnn_mnist.py`は、`if __name__ == \"__main__\":`から`tf.app.run()`で`main(unused_argv):`を実行します。最初に読み込むために、上記で定義した`parse_args()`を`main(unused_argv)`の冒頭に挿入して、`args`の中身を取り出します。このような記述になります。\n",
"\n",
"```\n",
"def main(unused_argv):\n",
" args = parse_args()\n",
" train_dir = args.train\n",
" test_dir = args.test\n",
" model_dir = args.model_dir\n",
" sm_model_dir = args.sm_model_dir\n",
" training_steps = args.training_steps\n",
"```\n",
"\n",
"これで学習データ・バリデーションデータの保存先を取得することができました。次にこれらのファイルを実際に読み込む処理を実装します。"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 1-2. 学習データのロード\n",
"\n",
"元のコードでは `tf.contrib.learn.datasets` を利用してダウンロード・読み込みを行っています。具体的には、`main(unused_argv)`のなかにある以下の5行です。今回はS3からデータをダウンロードするため、これらのコードは不要です。**ここで削除しましょう**。\n",
"```\n",
"mnist = tf.contrib.learn.datasets.load_dataset(\"mnist\")\n",
"train_data = mnist.train.images # Returns np.array\n",
"train_labels = np.asarray(mnist.train.labels, dtype=np.int32)\n",
"eval_data = mnist.test.images # Returns np.array\n",
"eval_labels = np.asarray(mnist.test.labels, dtype=np.int32)\n",
"```\n",
"\n",
"代わりにS3からダウンロードしたデータを読み込みコードを実装しましょう。環境変数から取得した `train_dir`や`test_dir` にデータを保存したディレクトリへのパスが保存され、それぞれ `/opt/ml/input/data/train`, `/opt/ml/input/data/test` となります。詳細は [ドキュメント](https://docs.aws.amazon.com/sagemaker/latest/dg/your-algorithms-training-algo-running-container.html#your-algorithms-training-algo-running-container-trainingdata) をご覧下さい。デフォルトの FILE Mode では、トレーニングコンテナ起動時に S3 からこれらのディレクトリへデータがコピーされ、PIPE モードを指定すると非同期にファイルがコピーされます。\n",
"\n",
"今回は npy のファイルを読むようにコードを書き換えれば良いので、以下のようなコードを追記します。パスが `train_dir`, `test_dir` に保存されていることをうまく利用しましょう。もとの npy のデータタイプは uint8 ですが、TensorFlow のレイヤーが対応する float32 や int32 に変換したり、画像の値を 0 から 1 の範囲内になるようにします。\n",
"```\n",
" import os\n",
" train_data = np.load(os.path.join(train_dir, 'image.npy')).astype(np.float32) * 1./255\n",
" train_labels = np.load(os.path.join(train_dir, 'label.npy')).astype(np.int32)\n",
" eval_data = np.load(os.path.join(test_dir, 'image.npy')).astype(np.float32) * 1./255\n",
" eval_labels = np.load(os.path.join(test_dir, 'label.npy')).astype(np.int32)\n",
"```\n",
"\n",
"#### 確認\n",
"\n",
"ここまでの修正で main(unused_argv) の冒頭の実装が以下の様になっていることを確認しましょう。\n",
"\n",
"```\n",
"def main(unused_argv):\n",
" args = parse_args()\n",
" train_dir = args.train\n",
" test_dir = args.test\n",
" model_dir = args.model_dir\n",
" sm_model_dir = args.sm_model_dir\n",
" training_steps = args.training_steps\n",
" \n",
" import os\n",
" train_data = np.load(os.path.join(train_dir, 'image.npy')).astype(np.float32) * 1./255\n",
" train_labels = np.load(os.path.join(train_dir, 'label.npy')).astype(np.int32)\n",
" eval_data = np.load(os.path.join(test_dir, 'image.npy')).astype(np.float32) * 1./255\n",
" eval_labels = np.load(os.path.join(test_dir, 'label.npy')).astype(np.int32)\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 1-3. 学習済みモデルの保存形式と出力先の変更\n",
"\n",
"TensorFlow では `serving_input_fn()` を実装することによって、モデルに対して入力を定義することができます。以下の関数を、トレーニングスクリプトの冒頭、 `import` の後に追加します。ここでは、28x28 の画像データ `x` を入力として与えることにします。\n",
"\n",
"\n",
"```\n",
"import numpy as np\n",
"import tensorflow as tf\n",
"\n",
"tf.logging.set_verbosity(tf.logging.INFO)\n",
"\n",
"def serving_input_fn():\n",
" inputs = {'x': tf.placeholder(tf.float32, [None, 28, 28], name=\"input_layer\")}\n",
" return tf.estimator.export.ServingInputReceiver(inputs, inputs)\n",
"```\n",
"\n",
"ただし、このままではこの関数は実行されず、SavedModel 形式のモデルは保存されません。main関数の最後でこの関数を呼びつつ、`export_savedmodel`でモデルが保存されるようにします。main 関数の最後は以下のような実装になります。\n",
"\n",
"```\n",
" # Evaluate the model and print results\n",
" eval_input_fn = tf.compat.v1.estimator.inputs.numpy_input_fn(\n",
" x={\"x\": eval_data}, y=eval_labels, num_epochs=1, shuffle=False)\n",
" eval_results = mnist_classifier.evaluate(input_fn=eval_input_fn)\n",
" print(eval_results)\n",
"\n",
" mnist_classifier.export_savedmodel(sm_model_dir, serving_input_fn)\n",
"```\n",
"ここで、`export_savedmodel` で出力先の `sm_model_dir` を指定することで、学習終了後にモデルが S3 にアップロードされるようにします。\n",
"\n",
"このままでは、学習を途中で中断した場合に、`export_savedmodel`にたどり着けず、何も保存されないまま学習を終えてしまいます。そこで、以下の`Estimator`のなかで、checkpoint を保存するフォルダを `model_dir=model_dir` で指定しましょう。これによって、学習途中のパラメータなどをS3に保存することができます。ここで保存されるものはあくまで checkpoint ですので、deploy する際は、SavedModel形式への変換が必要です。\n",
"\n",
"```\n",
" mnist_classifier = tf.estimator.Estimator(\n",
" model_fn=cnn_model_fn, model_dir=model_dir)\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 1-4. その他の変更 (学習ステップ数の変更)\n",
"\n",
"これは必須ではありませんが、hyperparameters から取得した `training-steps` で学習を行えるようにします。元のスクリプトでは、学習ステップ数は以下の `steps` で指定されています。\n",
"\n",
"```\n",
" mnist_classifier.train(\n",
" input_fn=train_input_fn,\n",
" steps=20000,\n",
" hooks=[logging_hook])\n",
"```\n",
"\n",
"これを以下のように修正しましょう。\n",
"```\n",
" mnist_classifier.train(\n",
" input_fn=train_input_fn,\n",
" steps=training_steps,\n",
" hooks=[logging_hook])\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Notebook 上でのデータ準備\n",
"\n",
"トレーニングスクリプトの書き換えは終了しました。 学習を始める前に、予め Amazon S3 にデータを準備しておく必要があります。この Notebook を使ってその作業をします。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import numpy as np\n",
"import boto3\n",
"import sagemaker\n",
"from sagemaker import get_execution_role\n",
"\n",
"sagemaker_session = sagemaker.Session()\n",
"\n",
"role = get_execution_role()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"機械学習に利用する手書き数字データセットの MNIST を利用します。`keras.datasets`を利用してデータセットをダウンロードし、それぞれ npy 形式で保存します。dataset のテストデータ `(X_test, y_test)` はさらにバリデーションデータとテストデータに分割します。学習データ `X_train, y_train` とバリデーションデータ `X_valid, y_valid` のみを学習に利用するため、これらを npy 形式でまずは保存します。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import tensorflow as tf\n",
"mnist = tf.keras.datasets.mnist\n",
"(X_train, y_train), (X_test, y_test) = mnist.load_data()\n",
"\n",
"X_valid = X_test[:5000]\n",
"y_valid = y_test[:5000]\n",
"X_test = X_test[5000:]\n",
"y_test = y_test[5000:]\n",
"\n",
"os.makedirs('data/train', exist_ok=True)\n",
"np.save(\"data/train/image.npy\", X_train)\n",
"np.save(\"data/train/label.npy\", y_train)\n",
"\n",
"os.makedirs('data/valid', exist_ok=True)\n",
"np.save(\"data/valid/image.npy\", X_valid)\n",
"np.save(\"data/valid/label.npy\", y_valid)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"これを Amazon S3 にアップロードします。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"train_data = sagemaker_session.upload_data(path='data/train', key_prefix='data/handson-tensorflow-mnist/train')\n",
"valid_data = sagemaker_session.upload_data(path='data/valid', key_prefix='data/handson-tensorflow-mnist/valid')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Local Mode によるトレーニングとコードの検証\n",
"トレーニングジョブを始める前に、Local Mode を使って、この Notebook インスタンス上でコンテナを実行してコードをデバッグしましょう。\n",
"`from sagemaker.tensorflow import TensorFlow` で読み込んだ SageMaker Python SDK の TensorFlow Estimator を作ります。\n",
"\n",
"ここでは、学習に利用するインスタンス数 `instance_count` や インスタンスタイプ `instance_type` を指定します。Local modeの場合は、`instance_type = \"local\"` と指定します。\n",
"\n",
"デバッグなので多くの学習ステップを回す必要はありません。`training-steps` を hyperparameters で渡すことができるようになりましたので、`hyperparameters = {\"training-steps\": 100}` だけ実行するようにします。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sagemaker.tensorflow import TensorFlow\n",
"\n",
"\n",
"instance_type = \"local\"\n",
"\n",
"mnist_estimator = TensorFlow(entry_point='cnn_mnist.py',\n",
" role=role,\n",
" instance_count=1,\n",
" instance_type=instance_type,\n",
" framework_version='1.13',\n",
" py_version='py3',\n",
" hyperparameters = {\"training-steps\": 100})\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"`estimator.fit` によりトレーニングを開始しますが、ここで指定する「チャネル」によって、環境変数名 `SM_CHANNEL_XXXX` が決定されます。この例の場合、`'train', 'test'` を指定しているので、`SM_CHANNEL_TRAIN`, `SM_CHANNEL_TEST` となります。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"mnist_estimator.fit({'train': train_data, 'test': valid_data})"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"`cnn_mnist.py` の中で書き換えを忘れた部分があったら、ここでエラーとなる場合があります。Local Mode ではクイックにデバッグができるので、正しく実行できるよう試行錯誤しましょう。\n",
"\n",
" `===== Job Complete =====`\n",
"と表示されれば成功です。"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 学習済みモデルの確認\n",
"\n",
"Amazon S3 に保存されたモデルは普通にダウンロードして使うこともできます。保存先は `estimator.model_data` で確認できます。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"mnist_estimator.model_data"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"AWS CLI を使ってノートブックインスタンス上にモデルをダウンロードして試しに推論します。\n",
"\n",
"このノートブックと同じディレクトリに tar.gz の形式でモデルをダウンロードして展開します。展開後のディレクトリ名は数字の羅列 (Unix time) になります。あとでモデルを読み込むため、正規表現を利用して、このフォルダ名を `model_dir` に保存します。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"!aws s3 cp $mnist_estimator.model_data ./\n",
"!tar -zxvf ./model.tar.gz"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import re \n",
"dir_list = os.listdir(\".\")\n",
"pattern = \"[0-9]+\"\n",
"for d in dir_list:\n",
" if re.match(pattern,d):\n",
" model_dir = d\n",
"print(\"model is downloaded to ./{}\".format(model_dir))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"テストデータセットからランダムに10枚選んでテストを行います。先ほど保存した`model_dir`からモデルをロードして、テストデータを入力します。ローカルモードでは学習を少ししか実行しなかったため、ほとんど正しい予測はできていないと思います。"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"test_size = 10\n",
"\n",
"select_idx = np.random.choice(np.arange(y_test.shape[0]), test_size)\n",
"test_sample = X_test[select_idx].reshape([test_size,28,28]) * 1./255\n",
" \n",
"with tf.Session(graph=tf.Graph()) as sess:\n",
" tf.saved_model.loader.load(sess, [\"serve\"], model_dir)\n",
" graph = tf.get_default_graph()\n",
" predict = sess.run('softmax_tensor:0',feed_dict={'input_layer:0': test_sample})\n",
" for i, pred in enumerate(predict):\n",
" print(\"Predict: {}, Ground Truth: {}\".format(np.argmax(pred), y_test[select_idx][i]))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. トレーニングジョブの発行\n",
"\n",
"推論されていればコードのデバッグは完了です。次に、Amazon SageMaker のトレーニングジョブとしてトレーニングします。データ・モデルの入出力は変わらず S3 なので、`instance_type` に `ml.` で始まる SageMaker のインスタンスを指定するだけで実行できます。(リストは[こちら](https://aws.amazon.com/sagemaker/pricing/instance-types/))"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"instance_type = \"ml.m5.xlarge\"\n",
"\n",
"mnist_estimator = TensorFlow(entry_point='cnn_mnist.py',\n",
" role=role,\n",
" instance_count=1,\n",
" instance_type=instance_type,\n",
" framework_version='1.13',\n",
" py_version='py3',\n",
" hyperparameters = {\"training-steps\": 2000})"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"mnist_estimator.fit({'train': train_data, 'test': valid_data})"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"----\n",
"```\n",
"Billable seconds: