サーバレスな機械学習アプリケーションを作ってみたい

## GoogleColaboratryやKaggleKernelでしか機械学習のコードを書いたことがない
実際に仕事として機械学習を使う際には、

* 学習データの準備
* モデルのトレーニング
* モデルの提供
    * REST APIなどで使えるような状態にすること

などが必要なのかなぁと思う今日この頃(他にもたくさんあるっぽいが割愛。[こちらのサイト](https://cloud.google.com/solutions/machine-learning/mlops-continuous-delivery-and-automation-pipelines-in-machine-learning)にいろいろ書いている)
そこで今回はAWSのSageMakerを使って少しだけ勉強してみた。


## まずはSageMakerEndPointを作るとこまで

![sagemeker-tutorial.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/4e530c00-0550-d76d-30e8-519c89a11a59.png)

最終的なイメージは上記の画像のような感じ。このうちの赤枠の部分だけをやってみました。
画像は[こちらの記事](https://qiita.com/kurakura0916/items/5d6e9254e5c654e098a7)より拝借しました。
具体的には、[こちらの公式サンプル](https://github.com/aws/amazon-sagemaker-examples/blob/master/sagemaker-python-sdk/pytorch_mnist/pytorch_mnist.ipynb)に沿って理解しながら実装を進めました。


## SageMakerのノートブックインスタンスを立ち上げ

1. AWSマネジメントコンソールからSageMakerを検索して選択
2. ノートブックインスタンスの作成
3. ノートブックの作成

### AWSマネジメントコンソールからSageMakerを検索して選択
サービスを検索するから検索するだけです。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/0ec7cf47-a80d-6068-2c60-d432fc831905.png' width='70%'/>

### ノートブックインスタンスの作成
以下のような画面が出てきますので、ノートブックインスタンス(赤枠)を押します。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/e5236651-df0b-3234-11d0-c58455f58f00.png' width='70%'/>


次に右上にあるノートブックインスタンスの作成を押します。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/80934aa2-76a0-3a87-e40e-690f4b36dbec.png' width='70%'/>

作成する際には`ノートブックインスタンス名`と`IAMロール`を設定する必要があるので適宜設定してください。
そして作成をすると、ノートブックインスタンスの画面に新たなインスタンスが追加されます。ステータスが`Pending`から`InService`となるまで待ってください。
`InService`となったら`Jupterを開く`を押して次への準備完了です。

### ノートブックの作成
Newから`conda_pytorch_p36`を選択肢作成する。以下の画像を参照。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/c5699b3b-9247-4a54-0564-c8550aa9f435.png' width='100%'/>

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/0610270f-45cb-25f0-67ab-5f242be2f2f8.png' width='30%'/>

## 学習データ準備

1. S3バケットを準備(こちらは解説を省略する)
2. mnistの画像をダウンロードとS3バケットへ保存

### mnistの画像をダウンロードとS3バケットへ保存
`torchvision`のdatasetsを使用してmnistをダウンロードする。
`datasets.MNIST()`の第一引数でダウンロード先のpathを指定する。

```python
from torchvision import datasets, transforms

datasets.MNIST('data', download=True, transform=transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
]))
```

`sagemaker`の`upload_data`を使用して、作成したS3バケットにmnistを保存します。

```python
import sagemaker

sagemaker_session = sagemaker.Session()

role = sagemaker.get_execution_role()
region = boto3.Session().region_name

bucket='作成したS3のバケット名'
prefix = 'sagemaker/mnist'

inputs = sagemaker_session.upload_data(path='data', bucket=bucket, key_prefix=prefix)
```

## 学習用スクリプトの作成

1. モデルの構築
2. SageMakerで扱える学習スクリプトに変換

### モデルの構築 
モデルは以下のように定義しました

```python
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
        self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
        self.conv2_drop = nn.Dropout2d()
        self.fc1 = nn.Linear(320, 50)
        self.fc2 = nn.Linear(50, 10)

    def forward(self, x):
        x = F.relu(F.max_pool2d(self.conv1(x), 2))
        x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
        x = x.view(-1, 320)
        x = F.relu(self.fc1(x))
        x = F.dropout(x, training=self.training)
        x = self.fc2(x)
        return F.log_softmax(x, dim=1)
```

学習部分は以下のように書きました。

```python
epoch_num = 5

model = Net().to(device)
optimizer = optim.SGD(model.parameters(), lr=0.01)
criterion = nn.CrossEntropyLoss()

for epoch in range(epoch_num): 
    running_loss = 0.0
    for data in trainloader:
        inputs, labels = data

        optimizer.zero_grad()

        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

    print('Finished Training')    
```

### SageMakerで扱える学習スクリプトに変換
SageMakerでは`sagemaker.pytorch`の`Pytorch()`を使うことで、学習時に別インスタンスを指定することができます。

```python
estimator = PyTorch(entry_point='mnist.py',
                    role=role,
                    framework_version='1.4.0',
                    py_versions='py3',
                    instance_count=2,
                    instance_type='ml.c4.xlarge',
                    hyperparameters={
                        'epochs': 6,
                        'backend': 'gloo'
                    })
```

モデルや学習部分を、ここの`entry_point`に渡すPythonスクリプトとして作成する必要があります。
ちなみにサンプルでは以下の引数がdeprecatedな形で定義されていたのと、requiredな引数があったので追加しました。

* train_instance_count → instance_count
* train_instance_type → instance_type
* 未指定 → py_versions

SageMakerで読み込むPythonスクリプトとして以下が必要となります。

* スクリプトの引数として指定されたものを受け取る
* 学習用のコードは`if __name__ == '__main__'`に入れる
* 学習後のモデルの保存先を環境変数`SM_MODEL_DIR`にする
* 保存したモデルをロードする`model_fn(model_dir)`を実装する

### スクリプトの引数として指定されたものを受け取る
これは変更する必要がないので以下のように書きました。

```python
if __name__ == '__main__':
    parser = argparse.ArgumentParser()

    parser.add_argument('--batch-size', type=int, default=64, metavar='N',
                        help='input batch size for training (default: 64)')
    parser.add_argument('--test-batch-size', type=int, default=1000, metavar='N',
                        help='input batch size for testing (default: 1000)')
    parser.add_argument('--epochs', type=int, default=10, metavar='N',
                        help='number of epochs to train (default: 10)')
    parser.add_argument('--lr', type=float, default=0.01, metavar='LR',
                        help='learning rate (default: 0.01)')
    parser.add_argument('--momentum', type=float, default=0.5, metavar='M',
                        help='SGD momentum (default: 0.5)')
    parser.add_argument('--seed', type=int, default=1, metavar='S',
                        help='random seed (default: 1)')
    parser.add_argument('--log-interval', type=int, default=100, metavar='N',
                        help='how many batches to wait before logging training status')
    parser.add_argument('--backend', type=str, default=None,
                        help='backend for distributed training (tcp, gloo on cpu and gloo, nccl on gpu)')
    
    parser.add_argument('--hosts', type=list, default=json.loads(os.environ['SM_HOSTS']))
    parser.add_argument('--current-host', type=str, default=os.environ['SM_CURRENT_HOST'])
    parser.add_argument('--model-dir', type=str, default=os.environ['SM_MODEL_DIR'])
    parser.add_argument('--data-dir', type=str, default=os.environ['SM_CHANNEL_TRAINING'])
```

### 学習用のコードは`if __name__ == '__main__'`に入れる
私は学習部分は`train()`メソッドとして分けました。

```python

def train(args):
    # 学習データ読み込み部分は省略
    tainloader = _get_train_data_loader(args.batch_size, args.data_dir, is_distributed, **kwargs)

    device = torch.device("cuda" if use_cuda else "cpu")
    model = Net().to(device)
    optimizer = optim.SGD(model.parameters(), lr=0.01)
    criterion = nn.CrossEntropyLoss()

    for epoch in range(1, args.epochs + 1):
        model.train()
        for inputs, labels in trainloader:
            inputs, labels - inputs.to(device), labels.to(device)
            optimizer.zero_grad()
            utputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()

    print('Finished Training')    

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    # argparser周りの記述は省略

    train(parser.parse_args())
```

### 学習後のモデルの保存先を環境変数`SM_MODEL_DIR`にする
モデルの保存先をargsでもらってきた`SM_MODEL_DIR`にする

```python

def save_model(model, model_dir):
    path = os.path.join(model_dir, 'model.pth')
    torch.save(model.cpu().state_dict(), path)

def train(args):
    # 学習部分は省略
    save_model(model, args.model_dir)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    # argparser周りの記述は省略

    train(parser.parse_args())
```

### 保存したモデルをロードする`model_fn(model_dir)`を実装する
おそらく、予測するときにこのメソッドが内部的に呼ばれるのかなぁという感じです。
実装はサンプルを参考にして以下のように実装しました。

```python
def model_fn(model_dir):
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = torch.nn.DataParallel(Net())
    with open(os.path.join(model_dir, 'model.pth'), 'rb') as f:
        model.load_state_dict(torch.load(f))
    return model.to(device)
```



## 学習とエンドポイントの作成と削除
ここまでやると、学習ができる状態になります。
あと残るは、以下のことだけ！

1. 学習
2. エンドポイントの作成と確認
3. エンドポイントの削除と確認

### 学習
SageMakerで扱える学習スクリプトに変換の際に宣言した`estimator`を使うと以下のように書くだけで簡単に学習が開始されます。

```python
estimator.fit({'training': inputs})
```


### エンドポイントの作成と確認
エンドポイント作成時にはエンドポイントとなるインスタンスタイプを指定することができます。
推論時にもある程度マシンの性能が必要なのでハイレベルなインスタンスにする必要があります。

```python
predictor = estimator.deploy(initial_instance_count=1, instance_type='ml.m4.xlarge')
```

上記のように実行すると、エンドポイントが作成されます。
作成されたかを確認する時は、AWSコンソールのSageMakerのダッシュボードの以下の部分へ進むと、確認できます。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49447/1f74de58-d3ea-ccc2-5743-69096426722d.png' width='70%'/>


### エンドポイントの削除と確認
エンドポイントを利用後はお金がかかってしまうため、エンドポイントを削除する必要があります。

```python
predictor.delete_endpoint()
```

また、先ほどと同じ画面から、エンドポイントが削除されたことが確認できます。


## おわりに
はじめてSageMakerを触ってみましたが、デプロイが楽であることに感動しました。
この勢いでREST APIとして利用できるところまで勉強したいと思います。