機械学習・自然言語処理の勉強メモ

学んだことのメモやまとめ

pytorch-transformersを触ってみる③

はじめに

前回は日本語でのpytorch-transformersの扱い方についてまとめました。
kento1109.hatenablog.com

これまでpytorch-transformersの基本的なところを英語・日本語で動かしてみました。

今回はもう一歩進んでfine tuningについてまとめてみます。

ドキュメントについては、以下を参考とします。
Examples — pytorch-transformers 1.0.0 documentation

準備

今回はGLUEタスクのうち、SST-2(評判分析)のデータセットで動かしてみます。
データセットについては、こちらのスクリプトを実行して入手しておきます。

実行

ものすごく簡単です。run_bert_classifier.pyに適切な引数を与えて実行するだけです。
と思ったのですが、github上にこのようなファイル名のスクリプトは存在しません。

このissueについては、既に以下で解決済です。
github.com

run_bert_classifier.pyは前のpytorch-pretrained-bertの時のスクリプトのようで、
今は、run_glue.pyという名称に代わっているみたいです。
githubのREADMEにもGLUEのfine tuningはrun_glue.pyと書いていますね。。

export GLUE_DIR=/path/to/glue
export TASK_NAME=MRPC

python ./examples/run_glue.py \
    --model_type bert \
    --model_name_or_path bert-base-uncased \
    --task_name $TASK_NAME \
    --do_train \
    --do_eval \
    --do_lower_case \
    --data_dir $GLUE_DIR/$TASK_NAME \
    --max_seq_length 128 \
    --per_gpu_eval_batch_size=8   \
    --per_gpu_train_batch_size=8   \
    --learning_rate 2e-5 \
    --num_train_epochs 3.0 \
    --output_dir /tmp/$TASK_NAME/

GPU環境の場合、1時間もしないうちに学習が終わります。
最後に、以下のような評価結果が表示されればOKかと思います。

08/22/2019 10:59:29 - INFO - __main__ -   ***** Eval results  *****
08/22/2019 10:59:29 - INFO - __main__ -     acc = 0.9231651376146789

環境設定などでハマらなければとても簡単です。

run_glue.py

GLUEのタスクでは、用意されたスクリプトを実行するだけで結果が得られました。
ほとんど頭で何か考える必要はありませんでした。
しかし、GLUE以外のタスク(自身で用意したデータセットなど)に適用する場合、このスクリプトは使えません。
なので、run_glue.pyでどうやってfine tuningを行ったかをコードを少し見て勉強しようと思います。
具体的には、run_glue.py内でこれまでに見たpretrained modelをどのように扱っているのか学びます。
※distributed training(分散学習)に関する部分は今回は扱いません。

model Instantiate

スクリプト内では、辞書形式で引数に応じたクラスを定義しています。

MODEL_CLASSES = {
    'bert': (BertConfig, BertForSequenceClassification, BertTokenizer),
    'xlnet': (XLNetConfig, XLNetForSequenceClassification, XLNetTokenizer),
    'xlm': (XLMConfig, XLMForSequenceClassification, XLMTokenizer),
    'roberta': (RobertaConfig, RobertaForSequenceClassification, RobertaTokenizer),
}

引数model_typeにてbertと与えられたら、bertのクラスが呼ばれるようになっています。
valueに存在するクラスについては、以前に扱いました。

BertForSequenceClassificationの出力層の数は「2」なので、分類問題用のモデルとなっています。

これらのクラスを以下のようにして変数にセットします。

config_class, model_class, tokenizer_class = MODEL_CLASSES[args.model_type]

まず、config_classの設定です。

 config = config_class.from_pretrained(args.config_name if args.config_name else args.model_name_or_path, num_labels=num_labels, finetuning_task=args.task_name)

デフォルトのハイパーパラメータ以外に以下を設定します。

  • num_labels
  • finetuning_task

finetuning_taskはドキュメントによると、checkpointの名前で用いるみたいです。

次に、tokenizerを設定します。

tokenizer = tokenizer_class.from_pretrained(args.tokenizer_name if args.tokenizer_name else args.model_name_or_path, do_lower_case=args.do_lower_case)

対象はbert-base-uncasedなので、do_lower_case=Trueとします。

最後にclass methodのfrom_pretrained()により、モデルをインスタンス化します。

model = model_class.from_pretrained(args.model_name_or_path, from_tf=bool('.ckpt' in args.model_name_or_path), config=config)
dataset

次に、datasetについてみていきます。
BERTなどは入力のdatasetが特殊なので、工夫が必要となります。
ちなみに元々の入力データは以下のようになっています。

it 's a charming and often affecting journey . 	1
unflinchingly bleak and desperate 	0

これをどのように変換するか確認します。
訓練データのload & tokenize & id変換はload_and_cache_examples()で行います。

train_dataset = load_and_cache_examples(args, args.task_name, tokenizer, evaluate=False)

load_and_cache_examplesを読んでみます。

examples = processor.get_dev_examples(args.data_dir) if evaluate else processor.get_train_examples(args.data_dir)
features = convert_examples_to_features(examples, label_list, args.max_seq_length, tokenizer, output_mode,
                                        cls_token_at_end=bool(args.model_type in ['xlnet']),
                                        # xlnet has a cls token at the end
                                        cls_token=tokenizer.cls_token,
                                        cls_token_segment_id=2 if args.model_type in ['xlnet'] else 0,
                                        sep_token=tokenizer.sep_token,
                                        sep_token_extra=bool(args.model_type in ['roberta']),
                                        # roberta uses an extra separator b/w pairs of sentences, cf. github.com/pytorch/fairseq/commit/1684e166e3da03f5b600dbb7855cb98ddfcd0805
                                        pad_on_left=bool(args.model_type in ['xlnet']),  # pad on the left for xlnet
                                        pad_token=tokenizer.convert_tokens_to_ids([tokenizer.pad_token])[0],
                                        pad_token_segment_id=4 if args.model_type in ['xlnet'] else 0,
                                        )

1行目で入力データからexamplesを作成します。
2行目でexamplesを引数として、各入力の形式に変換します。
2つ目のlabel_listはラベルが取り得る値のリストです。

label_list = ["0", "1"]

各exampleの処理を確認します。(一部コードを省略しています。)

features = []
for (ex_index, example) in enumerate(examples):

    tokens_a = tokenizer.tokenize(example.text_a)

    # The convention in BERT is:
    # (a) For sequence pairs:
    #  tokens:   [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP]
    #  type_ids:   0   0  0    0    0     0       0   0   1  1  1  1   1   1
    # (b) For single sequences:
    #  tokens:   [CLS] the dog is hairy . [SEP]
    #  type_ids:   0   0   0   0  0     0   0
    
    tokens = tokens_a + [sep_token]  # sep_token = '[SEP]'
    segment_ids = [sequence_a_segment_id] * len(tokens)

    tokens = [cls_token] + tokens
    segment_ids = [cls_token_segment_id] + segment_ids

    input_ids = tokenizer.convert_tokens_to_ids(tokens)

    # The mask has 1 for real tokens and 0 for padding tokens.
    input_mask = [1 if mask_padding_with_zero else 0] * len(input_ids)
    input_ids = input_ids + ([pad_token] * padding_length)  # pad_token=0
    input_mask = input_mask + ([0 if mask_padding_with_zero else 1] * padding_length)
    segment_ids = segment_ids + ([pad_token_segment_id] * padding_length)

    label_id = label_map[example.label]

    features.append(
        InputFeatures(input_ids=input_ids,
                      input_mask=input_mask,
                      segment_ids=segment_ids,
                      label_id=label_id))

return features

class InputFeatures(object):
    """A single set of features of data."""

    def __init__(self, input_ids, input_mask, segment_ids, label_id):
        self.input_ids = input_ids
        self.input_mask = input_mask
        self.segment_ids = segment_ids
        self.label_id = label_id

やっていることは、大まかに言うと
1. tokenize
2. [SEP], [CLS]の挿入
3. id変換
4. padding
5. mask
です。(2.を除いて一般的なNLPタスクとほとんど同じです。)

Training

変換した訓練データと定義済のモデルでfine tuningを行います。

global_step, tr_loss = train(args, train_dataset, model, tokenizer)

train()内では、samplerとdataloaderを定義します。

train_sampler = RandomSampler(train_dataset) if args.local_rank == -1 else DistributedSampler(train_dataset)
train_dataloader = DataLoader(train_dataset, sampler=train_sampler, batch_size=args.train_batch_size)

samplerとdataloaderについては以下が分かりやすいです。
PytorchのDataloaderとSamplerの使い方 - Qiita

そっから、ごにょごにょした後、エポックを回します。
イテレーションの内容は、一般的な学習とほとんど同じです。(一部コードを省略しています。)

tr_loss, logging_loss = 0.0, 0.0
model.zero_grad()
train_iterator = trange(int(args.num_train_epochs), desc="Epoch", disable=args.local_rank not in [-1, 0])
set_seed(args)  # Added here for reproductibility (even between python 2 and 3)
for _ in train_iterator:
    epoch_iterator = tqdm(train_dataloader, desc="Iteration", disable=args.local_rank not in [-1, 0])
    for step, batch in enumerate(epoch_iterator):
        model.train()
        batch = tuple(t.to(args.device) for t in batch)
        inputs = {'input_ids': batch[0],
                  'attention_mask': batch[1],
                  'token_type_ids': batch[2] if args.model_type in ['bert', 'xlnet'] else None,
                  # XLM don't use segment_ids
                  'labels': batch[3]}
        outputs = model(**inputs)
        loss = outputs[0]  # model outputs are always tuple in pytorch-transformers (see doc)

        loss = loss.mean() # mean() to average on multi-gpu parallel training

        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), args.max_grad_norm)  # default=1.0
      
        tr_loss += loss.item()

modelはBertForSequenceClassificationクラスですので、outputsには「(loss), logits, (hidden_states), (attentions)」が格納されています。
モデルの出力に関しては、ここを確認しておくと良いと思います。
Migrating from pytorch-pretrained-bert — pytorch-transformers 1.0.0 documentation

Saving best-practices

fine tuning後のモデルなどを保存しておきます。

model_to_save = model.module if hasattr(model, 'module') else model  # Take care of distributed/parallel training
model_to_save.save_pretrained(args.output_dir)
tokenizer.save_pretrained(args.output_dir)
# torch.save(model_to_save.state_dict(), output_model_file)

# Good practice: save your training arguments together with the trained model
torch.save(args, os.path.join(args.output_dir, 'training_args.bin'))

save_pretrainedでは、torch.save()を呼んで保存しています。

Evaluation

基本的に訓練時と同じです。
ただ、Samplerのところはランダムではなくシーケンシャルです。

# Note that DistributedSampler samples randomly
eval_sampler = SequentialSampler(eval_dataset) if args.local_rank == -1 else DistributedSampler(eval_dataset)
eval_dataloader = DataLoader(eval_dataset, sampler=eval_sampler, batch_size=args.eval_batch_size)

あと、lossと一緒にlogitも取り出す必要があります。

eval_loss = 0.0
nb_eval_steps = 0
preds = None
for batch in tqdm(eval_dataloader, desc="Evaluating"):
    outputs = model(**inputs)
    eval_loss += tmp_eval_loss.mean().item()

    if preds is None:
        preds = logits.detach().cpu().numpy()
        out_label_ids = inputs['labels'].detach().cpu().numpy()
    else:
        preds = np.append(preds, logits.detach().cpu().numpy(), axis=0)
        out_label_ids = np.append(out_label_ids, inputs['labels'].detach().cpu().numpy(), axis=0)

    eval_loss = eval_loss / nb_eval_steps
    preds = np.argmax(preds, axis=1)

    result = compute_metrics(eval_task, preds, out_label_ids)
    results.update(result)

ここらへんが理解できれば、別のタスクでもfine tuningできると思いました。

おわりに

run_glue.pyを走らせて、fine tuningを触ってみました。
run_glue.pyは動かすだけで、GLUEのfine tuningが出来るので凄く簡単です。
しかし、別のタスクに取り組むためには、run_glue.pyに頼らないコーディングが必要です。
次回は、run_glue.pyに存在しないタスク(CoNLL)に挑戦したいと思います。