SageMaker StudioからSageMaker SDK経由でHugging Faceモデルを呼び出してファインチューニングを実行する

最近お世話になることも多いため、ブログ記事でもHugging Face推し気味。何故サンプルノートブックは必要以上にデカいインスタンスでジョブを実行させようとしてくるのか。
2022.12.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

データアナリティクス事業本部 インテグレーション部 機械学習チームの貞松です。

本記事は「クラスメソッド 機械学習チーム アドベントカレンダー 2022」の22日目です。
昨日(21日目)は Shirota による 「コサイン類似度」で文書がどれだけ似ているかを調べてみた でした。

本記事では、SageMaker StudioからSageMaker SDK経由でHugging Faceモデルを呼び出してファインチューニングを実行する(正確にはHugging Face用のDeepLearning Container上でジョブを実行する)手順について解説します。

Hugging Faceとは

Hugging Faceは、機械学習を使用してアプリケーションを構築するためのツールを開発する企業です。
自然言語処理アプリケーション用に構築されたTransformersライブラリとHugging Face Hubという、ユーザーが機械学習モデルとデータセットを共有できるプラットフォームが特に有名です。

Transformersライブラリ

Transformersライブラリは、テキストや画像、音声用のTransformerモデルのオープンソース実装を含むPythonパッケージです。
PyTorch、TensorFlow、およびJAXと互換性があり、BERTやGPT等の有名なモデルの実装が含まれています。

Hugging Face Hub

Hugging Face Hubは、ユーザーがデータセットや事前トレーニング済みのモデル、機械学習プロジェクトのデモを共有できるプラットフォームです。
プロジェクトのディスカッションやプルリクエストなど、コード共有とコラボレーションに必要なGitHubライクな機能が含まれています。
また、ユーザーがGradioまたはStreamlitを使用して、機械学習アプリのWebベースのデモを構築できるホストサービスであるHugging Face Spacesを有しています。

Hugging Face on SageMakerについて

SageMaker上で実行されるHugging Faceは、SageMaker SDK経由でHugging Faceモデルに対するファインチューニングや推論の実行、デプロイを実行する為のジョブを操作します。 実際にジョブが実行される環境として、Hugging Faceモデル用のAWS Deep Learning Containersを利用します。

AWS公式のドキュメントは以下をご参照ください。

SageMakerのAPIリファレンス内のHugging Faceに関するAPIについては以下をご参照ください。

Hugging Face公式のHugging Face on SageMakerに関する解説、チュートリアル等については以下をご参照ください。

SageMaker Studio上でHugging Faceモデルのファインチューニングを実行する

Hugging Face公式がGitHub上に用意しているSageMaker用のサンプルノートブックを使用して、Hugging Faceモデルのファインチューニングを実行します。

対象とするモデルの具体的な内容としては、imdbデータセット(大規模な映画レビュー データセット)を用いた、テキストの2値分類モデル(POSITIVE or NEGATIVE)となっています。

↓imdbデータセットについて

↓使用したサンプルノートブック

SageMaker Studioを起動して、GitタブからサンプルノートブックのGitHubリポジトリをクローンします。

クローンが完了したら notebooks/sagemaker/01_getting_started_pytorch フォルダから sagemaker-notebook.ipynb を開きます。

ノートブックの実行環境として、imageに Pytorch 1.6 Python 3.6 CPU Optimized を選択します。

以降、ノートブックの中身について順番に解説していきます。

ライブラリインストール

まずは必要なライブラリのインストールです。
SageMaker SDKとHugging Faceのtransformers、datasetsライブラリをインストールしておきます。

!pip install "sagemaker>=2.48.0" "transformers==4.12.3" "datasets[s3]==1.18.3" --upgrade

環境設定や権限設定など

SageMaker SDKのHugging Face用モジュールをインポートします。
また、SageMakerのセッションを作成して、必要な権限を持つ実行ロールを割り当てます。

import sagemaker.huggingface
import sagemaker

sess = sagemaker.Session()
# sagemaker session bucket -> used for uploading data, models and logs
# sagemaker will automatically create this bucket if it not exists
sagemaker_session_bucket=None
if sagemaker_session_bucket is None and sess is not None:
    # set to default bucket if a bucket name is not given
    sagemaker_session_bucket = sess.default_bucket()

role = sagemaker.get_execution_role()
sess = sagemaker.Session(default_bucket=sagemaker_session_bucket)

print(f"sagemaker role arn: {role}")
print(f"sagemaker bucket: {sess.default_bucket()}")
print(f"sagemaker session region: {sess.boto_region_name}")

データロードと前処理

imdbデータセットをロードして、トークン化を施します。

from datasets import load_dataset
from transformers import AutoTokenizer

# tokenizer used in preprocessing
tokenizer_name = 'distilbert-base-uncased'

# dataset used
dataset_name = 'imdb'

# s3 key prefix for the data
s3_prefix = 'samples/datasets/imdb'

# load dataset
dataset = load_dataset(dataset_name)

# download tokenizer
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)

# tokenizer helper function
def tokenize(batch):
    return tokenizer(batch['text'], padding='max_length', truncation=True)

# load dataset
train_dataset, test_dataset = load_dataset('imdb', split=['train', 'test'])
test_dataset = test_dataset.shuffle().select(range(10000)) # smaller the size for test dataset to 10k 


# tokenize dataset
train_dataset = train_dataset.map(tokenize, batched=True)
test_dataset = test_dataset.map(tokenize, batched=True)

# set format for pytorch
train_dataset =  train_dataset.rename_column("label", "labels")
train_dataset.set_format('torch', columns=['input_ids', 'attention_mask', 'labels'])
test_dataset = test_dataset.rename_column("label", "labels")
test_dataset.set_format('torch', columns=['input_ids', 'attention_mask', 'labels'])

処理済みのデータセットについて、学習用、検証用それぞれをS3バケットに配置します。

import botocore
from datasets.filesystems import S3FileSystem

s3 = S3FileSystem()  

# save train_dataset to s3
training_input_path = f's3://{sess.default_bucket()}/{s3_prefix}/train'
train_dataset.save_to_disk(training_input_path,fs=s3)

# save test_dataset to s3
test_input_path = f's3://{sess.default_bucket()}/{s3_prefix}/test'
test_dataset.save_to_disk(test_input_path,fs=s3)

実行スクリプトの確認

ノートブックと同じフォルダに同梱されている実行スクリプト(ファインチューニングのトレーニングジョブで実行される処理の本体)の中身を確認します。
トレーニングに関する設定とデータの入出力先に関する設定を渡すことで、それらを使用したモデル学習の処理と結果の保存を実行してくれるようです。

!pygmentize ./scripts/train.py
from transformers import AutoModelForSequenceClassification, Trainer, TrainingArguments, AutoTokenizer
from sklearn.metrics import accuracy_score, precision_recall_fscore_support
from datasets import load_from_disk
import random
import logging
import sys
import argparse
import os
import torch

if __name__ == "__main__":

    parser = argparse.ArgumentParser()

    # hyperparameters sent by the client are passed as command-line arguments to the script.
    parser.add_argument("--epochs", type=int, default=3)
    parser.add_argument("--train_batch_size", type=int, default=32)
    parser.add_argument("--eval_batch_size", type=int, default=64)
    parser.add_argument("--warmup_steps", type=int, default=500)
    parser.add_argument("--model_name", type=str)
    parser.add_argument("--learning_rate", type=str, default=5e-5)

    # Data, model, and output directories
    parser.add_argument("--output_data_dir", type=str, default=os.environ["SM_OUTPUT_DATA_DIR"])
    parser.add_argument("--model_dir", type=str, default=os.environ["SM_MODEL_DIR"])
    parser.add_argument("--n_gpus", type=str, default=os.environ["SM_NUM_GPUS"])
    parser.add_argument("--training_dir", type=str, default=os.environ["SM_CHANNEL_TRAIN"])
    parser.add_argument("--test_dir", type=str, default=os.environ["SM_CHANNEL_TEST"])

    args, _ = parser.parse_known_args()

    # Set up logging
    logger = logging.getLogger(__name__)

    logging.basicConfig(
        level=logging.getLevelName("INFO"),
        handlers=[logging.StreamHandler(sys.stdout)],
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    )

    # load datasets
    train_dataset = load_from_disk(args.training_dir)
    test_dataset = load_from_disk(args.test_dir)

    logger.info(f" loaded train_dataset length is: {len(train_dataset)}")
    logger.info(f" loaded test_dataset length is: {len(test_dataset)}")

    # compute metrics function for binary classification
    def compute_metrics(pred):
        labels = pred.label_ids
        preds = pred.predictions.argmax(-1)
        precision, recall, f1, _ = precision_recall_fscore_support(labels, preds, average="binary")
        acc = accuracy_score(labels, preds)
        return {"accuracy": acc, "f1": f1, "precision": precision, "recall": recall}

    # download model from model hub
    model = AutoModelForSequenceClassification.from_pretrained(args.model_name)
    tokenizer = AutoTokenizer.from_pretrained(args.model_name)

    # define training args
    training_args = TrainingArguments(
        output_dir=args.model_dir,
        num_train_epochs=args.epochs,
        per_device_train_batch_size=args.train_batch_size,
        per_device_eval_batch_size=args.eval_batch_size,
        warmup_steps=args.warmup_steps,
        evaluation_strategy="epoch",
        logging_dir=f"{args.output_data_dir}/logs",
        learning_rate=float(args.learning_rate),
    )

    # create Trainer instance
    trainer = Trainer(
        model=model,
        args=training_args,
        compute_metrics=compute_metrics,
        train_dataset=train_dataset,
        eval_dataset=test_dataset,
        tokenizer=tokenizer,
    )

    # train model
    trainer.train()

    # evaluate model
    eval_result = trainer.evaluate(eval_dataset=test_dataset)

    # writes eval result to file which can be accessed later in s3 ouput
    with open(os.path.join(args.output_data_dir, "eval_results.txt"), "w") as writer:
        print(f"***** Eval results *****")
        for key, value in sorted(eval_result.items()):
            writer.write(f"{key} = {value}\n")

    # Saves the model to s3
    trainer.save_model(args.model_dir)

Estimatorの設定とファインチューニングの実行

学習ジョブで使用するハイパーパラメータを設定します。

from sagemaker.huggingface import HuggingFace

# hyperparameters, which are passed into the training job
hyperparameters={'epochs': 1,
                 'train_batch_size': 32,
                 'model_name':'distilbert-base-uncased'
                 }

Hugging Face Estimatorを作成してトレーニングジョブを実行します。
実行インスタンスの設定について、オリジナルのコードでは ml.p3.2xlarge を設定しているのですが、正直少しサイズダウンして ml.g4dn.2xlarge に変更して実行しても1時間も掛からずに実行が完了するので、ここは変更しても良いと思います(時間単価は約1/3になります)

huggingface_estimator = HuggingFace(entry_point='train.py',
                            source_dir='./scripts',
                            instance_type='ml.g4dn.2xlarge', # 元はml.p3.2xlarge
                            instance_count=1,
                            role=role,
                            transformers_version='4.12',
                            pytorch_version='1.9',
                            py_version='py38',
                            hyperparameters = hyperparameters)

設定したEstimatorでトレーニングジョブの実行を開始します。
※学習実行時のログはハチャメチャに長いので、ここでは割愛します。

huggingface_estimator.fit({'train': training_input_path, 'test': test_input_path})

エンドポイントデプロイと推論の実行

トレーニングジョブの実行が完了したら、エンドポイントデプロイを実行します。

predictor = huggingface_estimator.deploy(1,"ml.g4dn.xlarge")

作成されたエンドポイントにアクセスして、推論結果を取得します。

sentiment_input= {"inputs":"I love using the new Inference DLC."}

predictor.predict(sentiment_input)

ラベルとスコアが返ってきます。
LABEL_1はPOSITIVEに対応するラベルですね。どうやら正常に動作しているようです。

[{'label': 'LABEL_1', 'score': 0.8758626580238342}]

確認が終わったらエンドポイントは削除します。

predictor.delete_endpoint()

Extras. 以前実行されたトレーニングジョブのログ確認や学習済みモデルのロードを実行する

追加の確認として、実行済みのトレーニングジョブのログやその際の学習済みモデルをロードする便利機能を確認します。

まずは前述で実行されたトレーニングジョブの情報を直接取り出して中身を確認しておきます。
後々使用する為、トレーニングジョブ名は変数に格納しておきます。

# container image used for training job
print(f"container image used for training job: \n{huggingface_estimator.image_uri}\n")

# s3 uri where the trained model is located
print(f"s3 uri where the trained model is located: \n{huggingface_estimator.model_data}\n")

# latest training job name for this estimator
latest_training_job_name = huggingface_estimator.latest_training_job.name
print(f"latest training job name for this estimator: \n{latest_training_job_name}\n")

sagemaker_session.logs_for_job にトレーニングジョブ名を渡すことで、ジョブ実行時のログを一式取得することができます。
※学習実行時のログはハチャメチャに長いので、ここでは割愛します。
オリジナルのコードでは、トレーニングジョブ名として huggingface_estimator.latest_training_job.name を渡していますが、前述で変数に格納済みの為、これを利用します。

huggingface_estimator.sagemaker_session.logs_for_job(latest_training_job_name)

オリジナルのコードでは old_training_job_name が空になっていますが、ここも上記で格納しておいた変数を渡すことで正しくモデルをロードすることができます。
これにより、ロードしたモデルをコード上で操作したり、モデルの実体がどこのS3パスに格納されているか確認したりできます。

from sagemaker.estimator import Estimator

# job which is going to be attached to the estimator
old_training_job_name=latest_training_job_name

# attach old training job
huggingface_estimator_loaded = Estimator.attach(old_training_job_name)

# get model output s3 from training job
huggingface_estimator_loaded.model_data

まとめ

Hugging Face on SageMakerに関する概要と、SageMaker Studio上で実際にHugging Faceモデルのファインチューニングを実行する手順について解説しました。
SageMakerは、柔軟にコンピューティング環境を切り替えたり、スケールすることができる環境を即時用意することができるので、既存の学習済みモデルを活用した実験や実装と相性が良さそうです。
Hugging Faceと言えばTransformersライブラリということで、テキストや画像、音声に関する推論モデルをサクッと試してみたい方のお役に立てば幸いです。