Hugging Face謹製のTrainerが結構便利というお話

# はじめに
この記事は「[Kaggle Advent Calendar 2021](https://qiita.com/advent-calendar/2021/kaggle)」の3日目の記事です。

この記事では、結構便利（と個人的には思っている）なのにあまり使われていないHugging Face謹製のTrainerについて紹介します。

# 群雄割拠のPyTorchのTrainer系ライブラリ
PyTorchのTrainer系ライブラリは未だに「コレ」というものがない印象です。Kaggleの公開ノートブックでは自作Trainerが目立ち、コードを読む際に本質的でない部分で時間がかかってしまうこともしばしばです。

そこで、「Transformersを使う際にはHugging Face公式のTrainerが一番良いのでは？」と私は思っているのですが、あまり使われていないので紹介しようと思います。

# Huggig Face Tranerのメリット
- コードがかなりスッキリする
  - 最低限ならばTrainerを定義してtrainer.train()でOK
- Mixed Precision、Dynamic Padding、TPU、GPU並列での学習など各種高速化手法に対応
  - 私は使ったことがないですがDeepSpeedとかも
- （最近PyTorch公式で実装されてしまいましたが）label smoothingも簡単に試せる。
- TensorBoardやWandBとの連携も容易

# Huggig Face Tranerのデメリット
- 拡張性はあまり高くない
  - 特にlog周りはあまり自由度がない。例えば、マルチタスクで学習してそれぞれのlossをWandBでプロットとかは難しい
  - とはいえ、ドキュメントにも書いてある通り、元クラスを継承してオリジナルで実装すれば良い

# 使い方
Kggleの[CommonLitコンペ](https://www.kaggle.com/c/commonlitreadabilityprize)を例に[サンプルコード](https://www.kaggle.com/tealgreen0503/commonlit-roberta-huggingface-trainer)を用意しました。train loop周りがかなりスッキリしていることがわかると思います。（しっかりデバッグしていないので動かなかったらすみません）

詳しい使い方は[ドキュメント](https://huggingface.co/docs/transformers/main_classes/trainer)を参照してほしいのですが、一つ重要なポイントは**「モデルがXXXPreTrainedModelを継承している必要がある」**という点です。これを踏まえた上でモデルを作る最も楽な方法は**「XXXForSequenceClassificationをコピペして改変する」**方法です。

以下は、`RobertaForSequenceClassification`を改変したコードです。デフォルトの実装にmean poolingを入れるなどの改良を行っています。また、このモデルは`from_pretrained("model-name")`で学習済みモデルをロードできます。その際、モデルに合わせて最後の全結合層の初期化の方法を若干変えてくれるたりと、なかなか気が利いています。

<details><summary>RobertaForSequenceClassificationのコード（改変前のコード、長いので折り畳み）</summary><div>

```python
class RobertaForSequenceClassification(RobertaPreTrainedModel):
    _keys_to_ignore_on_load_missing = [r"position_ids"]

    def __init__(self, config):
        super().__init__(config)
        self.num_labels = config.num_labels
        self.config = config

        self.roberta = RobertaModel(config, add_pooling_layer=False)
        self.classifier = RobertaClassificationHead(config)

        self.init_weights()

    @add_start_docstrings_to_model_forward(ROBERTA_INPUTS_DOCSTRING.format("batch_size, sequence_length"))
    @add_code_sample_docstrings(
        processor_class=_TOKENIZER_FOR_DOC,
        checkpoint=_CHECKPOINT_FOR_DOC,
        output_type=SequenceClassifierOutput,
        config_class=_CONFIG_FOR_DOC,
    )
    def forward(
        self,
        input_ids=None,
        attention_mask=None,
        token_type_ids=None,
        position_ids=None,
        head_mask=None,
        inputs_embeds=None,
        labels=None,
        output_attentions=None,
        output_hidden_states=None,
        return_dict=None,
    ):
        r"""
        labels (:obj:`torch.LongTensor` of shape :obj:`(batch_size,)`, `optional`):
            Labels for computing the sequence classification/regression loss. Indices should be in :obj:`[0, ...,
            config.num_labels - 1]`. If :obj:`config.num_labels == 1` a regression loss is computed (Mean-Square loss),
            If :obj:`config.num_labels > 1` a classification loss is computed (Cross-Entropy).
        """
        return_dict = return_dict if return_dict is not None else self.config.use_return_dict

        outputs = self.roberta(
            input_ids,
            attention_mask=attention_mask,
            token_type_ids=token_type_ids,
            position_ids=position_ids,
            head_mask=head_mask,
            inputs_embeds=inputs_embeds,
            output_attentions=output_attentions,
            output_hidden_states=output_hidden_states,
            return_dict=return_dict,
        )
        sequence_output = outputs[0]
        logits = self.classifier(sequence_output)

        loss = None
        if labels is not None:
            if self.config.problem_type is None:
                if self.num_labels == 1:
                    self.config.problem_type = "regression"
                elif self.num_labels > 1 and (labels.dtype == torch.long or labels.dtype == torch.int):
                    self.config.problem_type = "single_label_classification"
                else:
                    self.config.problem_type = "multi_label_classification"

            if self.config.problem_type == "regression":
                loss_fct = MSELoss()
                if self.num_labels == 1:
                    loss = loss_fct(logits.squeeze(), labels.squeeze())
                else:
                    loss = loss_fct(logits, labels)
            elif self.config.problem_type == "single_label_classification":
                loss_fct = CrossEntropyLoss()
                loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
            elif self.config.problem_type == "multi_label_classification":
                loss_fct = BCEWithLogitsLoss()
                loss = loss_fct(logits, labels)

        if not return_dict:
            output = (logits,) + outputs[2:]
            return ((loss,) + output) if loss is not None else output

        return SequenceClassifierOutput(
            loss=loss,
            logits=logits,
            hidden_states=outputs.hidden_states,
            attentions=outputs.attentions,
        )

# 中略

class RobertaClassificationHead(nn.Module):
    """Head for sentence-level classification tasks."""

    def __init__(self, config):
        super().__init__()
        self.dense = nn.Linear(config.hidden_size, config.hidden_size)
        classifier_dropout = (
            config.classifier_dropout if config.classifier_dropout is not None else config.hidden_dropout_prob
        )
        self.dropout = nn.Dropout(classifier_dropout)
        self.out_proj = nn.Linear(config.hidden_size, config.num_labels)

    def forward(self, features, **kwargs):
        x = features[:, 0, :]  # take 
 token (equiv. to [CLS])
        x = self.dropout(x)
        x = self.dense(x)
        x = torch.tanh(x)
        x = self.dropout(x)
        x = self.out_proj(x)
        return x
```
</div></details>

改変後のコード

```python
class RobertaForSequenceRegression(RobertaPreTrainedModel):
    _keys_to_ignore_on_load_missing = [r"position_ids"]

    def __init__(self, config):
        super().__init__(config)
        super().__init__(config)
        config.hidden_dropout_prob = 0.0
        config.layer_norm_eps = 1e-7
        config.num_labels = 1
        config.problem_type = "regression"
        self.num_labels = config.num_labels
        self.config = config
        
        self.roberta = RobertaModel(config, add_pooling_layer=False)
        self.regressor = RobertaRegressionHead(config)

        self.init_weights()

    def forward(
        self,
        input_ids=None,
        attention_mask=None,
        token_type_ids=None,
        position_ids=None,
        head_mask=None,
        inputs_embeds=None,
        labels=None,
        output_attentions=None,
        output_hidden_states=None,
        return_dict=None
    ):
        assert self.config.problem_type == "regression" and self.num_labels == 1
        
        outputs = self.roberta(
            input_ids,
            attention_mask=attention_mask,
            token_type_ids=token_type_ids,
            position_ids=position_ids,
            head_mask=head_mask,
            inputs_embeds=inputs_embeds,
            output_attentions=output_attentions,
            output_hidden_states=output_hidden_states,
            return_dict=return_dict,
        )
        last_hidden_states = outputs[0]
        logits = self.regressor(last_hidden_states, attention_mask)
        
        loss = None
        if labels is not None:
            loss = torch.sqrt(F.mse_loss(logits.view(-1), labels.view(-1)))
        
        return SequenceClassifierOutput(
            loss=loss,
            logits=logits,
            hidden_states=outputs.hidden_states,
            attentions=outputs.attentions,
        )

class RobertaRegressionHead(nn.Module):
    """Head for sentence-level regression tasks."""
    def __init__(self, config):
        super().__init__()
        self.dense = nn.Linear(config.hidden_size, config.hidden_size)
        self.activation = nn.Tanh()
        self.layer_norm = nn.LayerNorm(config.hidden_size)
        self.out_proj = nn.Linear(config.hidden_size, config.num_labels)

    def forward(self, last_hidden_states, attention_mask):
        x = mean_pooling(last_hidden_states, attention_mask)
        x = self.dense(x)
        x = self.activation(x)
        x = self.layer_norm(x)
        x = self.out_proj(x)
        return x    

@torch.jit.script
def mean_pooling(last_hidden_state, attention_mask):
    attention_mask = attention_mask.unsqueeze(-1).expand(last_hidden_state.size())
    sum_hidden_state = torch.sum(last_hidden_state * attention_mask, 1)
    sum_mask = torch.clamp(attention_mask.sum(1), min=1e-9)
    embeddings = sum_hidden_state / sum_mask
    return embeddings
```



# その他使う際のポイント
- 正解ラベルの名前は`labels`にする必要があります。
- Hugging Faceが提供している[datasetsライブラリ](https://huggingface.co/docs/datasets/)を使うとさらに便利。
  - tokenizeをバッチ処理してくれるので高速
- 学習中は重みを`temp_dir`などに保存しておいて、学習後に`trainer.save_model(ckpt_path)`で保存すると余計なファイル（`optimizer.pt`など）が保存されなくて良いです。
  - `Trainer`にtokenizerも渡しているとtokenizerの情報も一緒に保存されるので、推論時に`tokenizer.from_pretrained(ckpt_path)`で呼び出せます。（特にインターネット接続ができない推論時に便利です）
- optimizerはデフォルトで`AdamW`が使われますが、オリジナルのoptimizerを渡すこともできます。
  - ちなみにデフォルトでもLayerNorm層とbiasのweight_decayを自動で0にしてくれるなど気が利いています。
- 推論のコードも`trainer.predict(test_dataset)`とするとスッキリします。

# おわりに
Transformersはアプデのスパンが短くどんどん新しい機能が追加されていく点が好きです。
少しでも参考になれば幸いです。