[初心者向け] Amazon SageMaker で PyTorch を使って Cifar10 を特徴抽出して画像分類してみる

2020.04.27

はじめに

おはようございます、もきゅりんです。

最近は個人的な取り組みの一環として、機械学習の学習に取り組んでいます。

前回 は、XGBoost で赤ワインの品質を分類しました。

今回は、PyTorch をSageMaker(以下SM)で使ってみたく、みんな大好き Cifar10 *1を使って分類します。

基本的にやってることは下記 PyTorch のチュートリアル内容です。

TRAINING A CLASSIFIER

ただ、チュートリアルをやるだけでは物足りなかったので、転移学習 をやってみようと思った次第です。

転移学習の参考も同じく PyTorch のチュートリアルです。

TRANSFER LEARNING FOR COMPUTER VISION TUTORIAL

画像分類では、ディープラーニングのConvolutional Neural Network(以下CNN)という手法を利用することが多いようです。

なお、ここではCNNがどのようなものかを改めて説明しません。(ネットにいくらでも説明資料があるため)

PyTorchをSMを使って転移学習で画像分類するが趣旨になります。

なお、自分は専門的なデータサイエンティストでも何でもないので、無駄、非効率な作業を行っているかもしれない点、ご了承下さい。

転移学習とは

転移学習もいくらでも説明資料はネットにありそうですが、簡潔に説明しておきます。

転移学習とは、ある問題を解決するのに学習した知識を、別の関連した問題の解決に適用することです。

たとえば、車を認識することを学んだ知識は、トラックを認識しようとするときに適用できます。

さまざまな動物の画像が集まった巨大なデータセットで学習した知識は、犬や猫の分類にも役立ちそうですよね。

ここで扱われている知識とは、ネットワーク(モデル) *2と置き換えて貰えればと思います。

画像分類の転移学習でよく利用されるのは、 ImageNetという140万以上の巨大な日常的な画像データセットで学習されたネットワークです。

PyTorch では、そのような事前に学習したネットワーク(モデル)を簡単に利用したネットワーク(モデル)を構築することができます。 *3

TORCHVISION.MODELS

転移学習の手法について

転移学習の手法は自分の知る限り、2つあります。

feature extraction (特徴抽出)fine-tuning (ファインチューニング) です。 *4

feature extraction は事前に学習されたネットワーク最終層の分類器のみを今回のデータセット用に取り替えて、改めて学習させる手法です。

最終層以外のネットワーク層は「そのまま」で利用します。(学習させてしまうと、これまで学習してきた知識がパーになってしまいます)

fine-tuning は最終層の分類器を当問題のために学習させるのは feature extraction と変わりませんが、事前に学習されたネットワーク層を「そのまま使いません」。

事前に巨大なデータセットで学習された「いくつかの層も学習」させるようにします。

PyTorchの転移学習チュートリアルにも2つの手法について、簡潔に説明が述べられています。

この例では、Resnet18 を使った feature extraction (特徴抽出) を行います。

前提

  • データを格納するS3バケットがあること
  • Jupyterノートブックが作成されていること
  • IAM権限を設定・更新できること

こちらの作業については下記を参照下さい。

はじめてのSageMaker みんな大好きアイリスデータを使って組み込みアルゴリズムで分類してみる

やること

10種類の画像を学習させて分類できるようにしようぜ! が趣旨です。

  1. データのロード、準備、S3アップロード
  2. モデルでの学習
  3. モデルのデプロイ
  4. モデルの検証
  5. 後片付け

1. データのロード、準備、S3アップロード

なお、PyTorchを使うNotebookはpytorchを選びましょう。

(torchがない!?なぜだ!?となったタイミングがありました)

pytorch notebook

必要なライブラリとデータのロード

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models

S3バケットにデータをアップロードするための準備です。

%%time

import sagemaker
import os
import boto3
import re
import numpy as np

sagemaker_session = sagemaker.Session()

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

bucket='YOUR_BUCKET_NAME'
prefix = 'sagemaker/cnn-cifar10'
# customize to your bucket where you have stored the data
bucket_path = 'https://s3-{}.amazonaws.com/{}'.format(region,bucket)

ここらへんはPyTorchのチュートリルに沿っていますが、

データ形式および正規化を施したローカルにDownloadされたデータを訓練データ、テストデータにセットします。

%%time
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]),
])

train_data = datasets.CIFAR10(root='../data', train=True, download=True, transform=transform)
test_data = datasets.CIFAR10(root='../data', train=False, download=True, transform=transform)

S3バケットにデータをアップロードします。

%%time
inputs = sagemaker_session.upload_data(path='../data', bucket=bucket, key_prefix=prefix)
print('input spec (in this case, just an S3 path): {}'.format(inputs))

2. モデルでの学習

カスタム PyTorch コードを利用するために下記を参考にPythonスクリプトを作成します。

# feature_extract_cifar10.py
import argparse
import json
import sagemaker_containers
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models
import torch
import torchvision
import torchvision.transforms as transforms
import torch.optim as optim
import logging
import os
import sys
import time

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler(sys.stdout))

def _train(args):
    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]),
    ])

    train_data = datasets.CIFAR10(
        root='../data', train=True, download=True, transform=transform)
    test_data = datasets.CIFAR10(
        root='../data', train=False, download=True, transform=transform)

    torch.manual_seed(42)  # for reproducible results
    train_loader = DataLoader(train_data, batch_size=100, shuffle=True)
    test_loader = DataLoader(test_data, batch_size=100, shuffle=False)

    logger.debug("Processes {}/{} ({:.0f}%) of train data".format(
        len(train_loader), len(train_loader.dataset),
        100. * len(train_loader) / len(train_loader.dataset)
    ))

    logger.debug("Processes {}/{} ({:.0f}%) of test data".format(
        len(test_loader), len(test_loader.dataset),
        100. * len(test_loader) / len(test_loader.dataset)
    ))

    model = models.resnet18(pretrained=True)
    for param in model.parameters():
        param.requires_grad = False

    # Parameters of newly constructed modules have requires_grad=True by default
    num_ftrs = model.fc.in_features

    model.fc = nn.Sequential(nn.Linear(num_ftrs, 1024),
                                nn.ReLU(),
                                nn.Dropout(0.4),
                                nn.Linear(1024, 10),
                                nn.LogSoftmax(dim=1))

    criterion = nn.CrossEntropyLoss()

    optimizer = optim.SGD(model.parameters(), lr=args.lr,
                          momentum=args.momentum)

    epochs = args.epochs
    batch_size = args.batch_size

    since = time.time()

    for epoch in range(epochs):
        print('Epoch {}/{}'.format(epoch, epochs - 1))
        print('-' * 12)
        running_loss = 0.0
        running_corrects = 0

        for i, (X_train, y_train) in enumerate(train_loader):
            i += 1
            optimizer.zero_grad()

            y_pred = model(X_train)
            _, preds = torch.max(y_pred, 1)
            loss = criterion(y_pred, y_train)
            loss.backward()
            optimizer.step()

            running_loss += loss.item() * X_train.size(0)
            running_corrects += torch.sum(preds == y_train)

#         scheduler.step()

            epoch_loss = running_loss / (batch_size*i)
            epoch_acc = running_corrects.double() * 100/(batch_size*i)
        # statistics
            if i % 100 == 0:
                print(
                    f'Loss: {epoch_loss:4f} batch: {i:4} [{batch_size*i:6}/50000] Accuracy: {epoch_acc:7.3f}%')

    time_elapsed = time.time() - since
    print('Training complete in {:.0f}m {:.0f}s'.format(
        time_elapsed // 60, time_elapsed % 60))
    return _save_model(model, args.model_dir)


def model_fn(model_dir):
    device = "cuda" if torch.cuda.is_available() else "cpu"

    model = models.resnet18(pretrained=True)
    for param in model.parameters():
        param.requires_grad = False

    # Parameters of newly constructed modules have requires_grad=True by default
    num_ftrs = model.fc.in_features

    model.fc = nn.Sequential(nn.Linear(num_ftrs, 1024),
                                nn.ReLU(),
                                nn.Dropout(0.4),
                                nn.Linear(1024, 10),
                                nn.LogSoftmax(dim=1))
    if torch.cuda.device_count() > 1:
        model = nn.DataParallel(model)

    with open(os.path.join(model_dir, 'model.pth'), 'rb') as f:
        model.load_state_dict(torch.load(f))
    return model.to(device)

def _save_model(model, model_dir):
    path = os.path.join(model_dir, 'model.pth')
    # recommended way from http://pytorch.org/docs/master/notes/serialization.html
    torch.save(model.cpu().state_dict(), path)

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

    # Data and model checkpoints directories
    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)')

    # Container environment
    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'])
    parser.add_argument('--num-gpus', type=int,
                        default=os.environ['SM_NUM_GPUS'])

    _train(parser.parse_args())

基本的にやっていることは、PyTorchで実行する内容をバラバラに関数として定義して、ハイパーパラメータを引数で取得できるようにします。

MNIST Training using PyTorch

こちらも参考になりました。ありがとうございました。

AWS SageMakerでの機械学習モデル開発フロー(PyTorch)

Kerasを使ったユースケースは、弊社記事も参考になります。

Amazon SageMakerのScript modeを使ってKerasを動かしてみた #reinvent

ハイパーパラメータをそれぞれ設定して、モデルの訓練を実行します。

from sagemaker.pytorch import PyTorch

hyper_param = {
    'epochs':100,
    'batch-size': 100,
    'lr': 0.01,
    'momentum': 0.9,
}

estimator = PyTorch(entry_point='feature_extract_cifar10.py',
                            hyperparameters=hyper_param,
                            role=role,
                            framework_version='1.2.0',
                            train_instance_count=2,
                            train_instance_type='ml.c5.xlarge')
estimator.fit({'training': inputs}, logs=True)

なお、現在(2020/4/24)、 framework_version が1.3および1.4だと

AlgorithmError: ExecuteUserScriptError: Command エラーが出て実行できませんでした。

ちなみに、ローカルPCで転移学習しない5層程度のネットワークモデルの訓練データの正解率は、

50epochで85%くらい までいきました。(3,40分)

検証データの正解率は65~70%でした。

こいつあ、きっと少なくとも訓練データの正解率90%超は間違いないよな!?

...

...

1時間以上経過

...

cnn_result

え、ええーー。。

ショックで何これ?とうなだれていると、社内で教えて頂きます。

slack-comment

なるほど。。

どうも torchvision のデータセットが元の写真サイズとは異なるためらしい。

初歩的だあ。。(今ちょっとやり直す元気もうない。)

3. モデルのデプロイ

まぁ結果はどうあれ、とりあえずやりたいことはできたからいいや、ということでエンドポイントを作成して

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

4. モデルの検証

10,000のテストデータを使って正解率を確認してみます。

import numpy as np
correct = 0
total = 0

with torch.no_grad():
    for data in test_loader:
        images, labels = data
        outputs = predictor.predict(images.numpy())
        _, predicted = torch.max(torch.from_numpy(np.array(outputs)), 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()

print('Accuracy of the network on the 10000 test images: %d %%' % (
    100 * correct / total))

50% という惨憺たる結果でした。。

5. 後片付け

忘れずにエンドポイントは削除します。

削除しないとお金が発生しますのでご注意下さい。

estimator.delete_endpoint()

インスタンスも不用であれば削除しちゃいましょう。

S3バケットの存在も忘れずに。

最後に

結果をスピーディーに出していくためには一連の作業を効率化、ハイパーパラメータの調整手法を学ぶ必要があるなと思いました。

ハイパーパラメータの値で結構な差が出る、恐ろしい、、、ということも学びました。

(逆に言えば、何やってるのか全く分からなくてもハイパーパラメータさえうまく調整できればある程度結果は出せるのだろうと思います。)

GPUって高いけど速いんだなーーということも分かりました。

以上です。

引き続き学習を進めていきます。

どなたかのお役に立てば幸いです。

参考:

脚注

  1. CIFAR10とは、犬とか猫とか10の異なるクラスで60,000の32x32カラー画像が含まれたデータセットです
  2. 何らかのインプットが与えられると何らかの出力をするものをモデルと考えて下さい。
  3. Kerasでもできます。きっと他のライブラリでもできます。
  4. 余談ですが、個人的に和訳をあてると余計何だかよく分からなくなるので、英語のままでよい気がしています。