😀

Bertをファインチューニングして自然言語処理のコンペに参加してみる

2024/08/22に公開

皆さんこんにちは、こんばんは。突然自然言語系のコンペに参加したいと思い立ち、どうせやるならでかいモデルを使ってみたいの一心でBertを使ってみようと思いやってみたのですが、意外と手こずることが多く、備忘録も兼ねて投稿しようと思います。

はじめに

紹介するコードの変数のにところどころconfigとありますが、ハイパーパラメータなどをconfig.yamlに保存して実行しているためそのような表記になっています。
適宜ご自身でパラメータを定めてください

今回使用したコードは以下から飛べます。
https://colab.research.google.com/drive/1pQpIpDpIOdrxVr7lUcBWjBETpiZ0l1zR?hl=ja#scrollTo=O9f3ZmvCqzT6

1.今回参加するコンペ(使用したデータ)

データサイエンス系のコンペとして一番有名なのはkaggleなのは疑いようがないのですが、布教を兼ねてNishikaのコンペを使ってやってみようと思います。
リンクはこちらになります。(ログインが必要です)

1.1 コンペの内容

芥川龍之介が書いた文なのかどうかを見分けるタスクです。
テーブルデータ形式でデータが与えられていて、['body']は文を、['author']は芥川龍之介が書いたものならば1が、そうでなかったら0のラベルが与えられています。

1.2 データの取得

Nsihikaはご丁寧に、データのリンクを添付してくれます。そのままZipをダウンロードしてくるのもいいのですが、エンジニアっぽくpythonのコードでローカルに落としましょう。

import os
import urllib.request
import zipfile
import requests


url="https://s3.ap-northeast-1.amazonaws.com/nishika.assets.private/competitions/1/data/data.zip?response-content-disposition=attachment%3B%20filename%3Ddata.zip&AWSAccessKeyId=ASIA3NMWWMCV5MMS6LBR&Signature=JkEO0hYZ0XmaQ3efd53ziCfLumI%3D&x-amz-security-token=IQoJb3JpZ2luX2VjEBIaDmFwLW5vcnRoZWFzdC0xIkgwRgIhAOzwmVHWWGCl5ol1MIzMoYyG7DuxXXlDq2hIm9Ls%2F7AgAiEAtQr4FmHqklytp%2Bgwg5Hu5zz44X5njOh9Epi8pbz1nU0qiwQI%2B%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAFGgw3ODQ2ODQ0NDE3NzEiDCxs7PZE835injzJGirfA6dKEBghaNH4DC%2FzN%2BUPy%2BAnoZk5SHWGGItPkHGqUI8jRQu2icwyseagRCiPg8E6YQ%2FwYrcsYe02CgN05JH3uqXreYF6nM7zDO0xgvOb1R5vPVC%2BH4k4CP6IyyrB3DAA5j0Br2hi1I698moFKxgkB711QOqC55V4Wtilh3eZCm8uDbc96Kl4V%2FXA1v5W2s9eHg%2BW%2FGmLLKnpz9oBVMrg7LEYQ6ZJ7IAmKOGk%2B%2BLaO0wMB0AXE9DbycN3XAJ0rR3x1j7QC1Ixrq92uq5oq72BYjF5Z%2BJ75iO%2F2NadTIQH70FS3O8w%2BpZqQqNRQRZibX%2F7EjDP13JlEwlYJVHv1fOTe3L72nEKlyCG2I7GGVByMOJaS8e828%2BVUX01sktCqiRDsT2DSFtcMYQ5xpuXSbvG1%2FxSmVQWM96LZV55af07vmaZ7TmO8GQWnNFCZfTPr8OvDZ0qucx70leDVaMvZRAF4stBsNt1eLLJ6VYeuncIuJy9EbATsdKSfmoyZz4NAxgPg5GP%2B%2FGerdGQqVrERsu6G5D45n8nARC6iMmWd%2FoguEo8NoEiE3jcpL7I5odbbHQmMstZTExh1UZUX%2BCJWQl5%2FnIUxL80Fq9ycZkDr1TD76eUCven%2BwYevjNr5UHvSzTiMOOoy7UGOqQBzuQMsWaFxNdZ7fk81ECVxkmpEi2TXBbPiNv4Vw8OftEy4ibP10ecRGb8GJazMvXGYt6K6l7PO2fqq08lHC1NaYcDfyQSi0tJ5J0WIiGbwcK84OrRxnyj8gZEzKJBUzG6k80w7fGuqtI0VBTsc6bmHYwBQn7K5JPjC1M9NNIj8ijvvSJQsQSGjBoaMoIotIwSPSczc3EvOWSHGBiBiWa4MeSVRUU%3D&Expires=1723016508"
urlData = requests.get(url).content
file_path='../data/input/train.zip'

with open(file_path, 'wb') as f:
    response = requests.get(url, stream=True)
    if response.status_code == 200:
        for chunk in response.iter_content(1024):
            f.write(chunk)
    else:
        print(f"Download failed. Status code: {response.status_code}")

if os.path.exists(file_path):
  with zipfile.ZipFile(file_path) as existing_zip:
    existing_zip.extractall('../data/input')
else:
  print(f"The file {file_path} does not exist.")

os.remove('../data/input/train.zip')

urlのところはdata.zipのリンクを指定します。
これで無事にtrain.csvとtest.csvが入手できました。

2. Bertについて

Bertの詳しい説明はこの場では割愛しますが、kaggleやqiitaに詳しい説明を書いている人がいたのでリンクを共有しておきます。

  1. Bertの詳しい説明
  2. 論文解説
  3. [Transformerの記事](https://zenn.dev/yukiyada/articles/59f3b820c52571)

2.1 Tokenizerについて

Bertに限らず自然言語処理では単語をtoken化してくれるtokenizerを使用することになります。
コンピュータは人と違って文字そのものを理解することはできないので数字(ベクトル)に置き換える必要があります。以下のコードを使ってtokenizeします。
なお、今回は日本語のタスクですので、tokenizerには東北大学が公開している
'cl-tohoku/bert-base-japanese-whole-word-masking'
を使用しました。

from torch.utils.data import Dataset, DataLoader

class BERTDataSet(Dataset):
    
    def __init__(self,data,tokenizer,mode='train'):
        self.data=data
        self.mode=mode
        self.tokenizer=tokenizer
        self.sentences = self.data["body"].tolist()
        if self.mode != 'test':
            self.labels = self.data["author"].tolist()
        
    def __len__(self):
        
        return len(self.sentences)
    
    def __getitem__(self,idx):
        
        sentence = self.sentences[idx]

        if self.mode != 'test':
            label = torch.tensor(self.labels[idx],dtype=torch.long)
        
        bert_sens = tokenizer.encode_plus(
                                sentence,
                                add_special_tokens = True, 
                                max_length = 512, 
                                padding = 'max_length', 
                                return_attention_mask = True,
                                return_tensors='pt',
                                truncation=True)
        ids = bert_sens['input_ids'].clone().detach().squeeze()
        mask = bert_sens['attention_mask'].clone().detach().squeeze()
        token_type_ids = bert_sens['token_type_ids'].clone().detach().squeeze()

        if self.mode != 'test':
            return {
                'ids': ids,
                'mask': mask,
                'token_type_ids': token_type_ids,
                'labels': label
            }
        else:
            return {
                    'ids': ids,
                    'mask': mask,
                    'token_type_ids': token_type_ids,
                }
    
tokenizer=BertJapaneseTokenizer.from_pretrained(config['MODEL_NAME'])

ここで、encode_plusの引数について説明します。

  1. sentence : Tokenizeする入力文
  2. add_special_tokens : 特殊トークン(例えば、BERTの場合は[CLS]や[SEP])を追加する
  3. max_length : トークン列の最大長を指定する。(maxは512)
  4. return_attention_mask : アテンションマスクを返す。アテンションマスクは、モデルがどのトークンを無視すべきかを示す(パディングトークンを無視するために使用)。
  5. truncation : トークン列がmax_lengthを超える場合に切り捨てを行う

なお、sentenceの系列長を知りたい場合は以下のコードを実行してください

    sen_length = []

    for sentence in tqdm(df["body"]):

        token_words = tokenizer.encode_plus(sentence)["input_ids"]
        sen_length.append(len(token_words))

    print('maxlenth of all sentences are  ', max(sen_length))

2.2 モデルの作成

まずは、モデルを定義します。今回は'cl-tohoku/bert-base-japanese-whole-word-masking'を使います。以下のコードで定義をします。

from transformers import BertModel

bert_model = BertModel.from_pretrained('cl-tohoku/bert-base-japanese-whole-word-masking')

これを定義してモデルをみると下の図が出てきます。

BertModel(
  (embeddings): BertEmbeddings(
    (word_embeddings): Embedding(32000, 768, padding_idx=0)
    (position_embeddings): Embedding(512, 768)
    (token_type_embeddings): Embedding(2, 768)
    (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
    (dropout): Dropout(p=0.1, inplace=False)
  )
  (encoder): BertEncoder(
    (layer): ModuleList(
      (0-11): 12 x BertLayer(
        (attention): BertAttention(
          (self): BertSdpaSelfAttention(
            (query): Linear(in_features=768, out_features=768, bias=True)
            (key): Linear(in_features=768, out_features=768, bias=True)
            (value): Linear(in_features=768, out_features=768, bias=True)
            (dropout): Dropout(p=0.1, inplace=False)
          )
          (output): BertSelfOutput(
            (dense): Linear(in_features=768, out_features=768, bias=True)
            (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
            (dropout): Dropout(p=0.1, inplace=False)
          )
        )
        (intermediate): BertIntermediate(
          (dense): Linear(in_features=768, out_features=3072, bias=True)
          (intermediate_act_fn): GELUActivation()
        )
        (output): BertOutput(
          (dense): Linear(in_features=3072, out_features=768, bias=True)
          (LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
          (dropout): Dropout(p=0.1, inplace=False)
        )
      )
    )
  )
  (pooler): BertPooler(
    (dense): Linear(in_features=768, out_features=768, bias=True)
    (activation): Tanh()
  )
)

これに出力層を追加してあげます。今回、分類するのは0か1なのでnum_labelsは2に設定します。
一応Dropoutも挟んでおきます。

# 線形層の追加
class BertForSequenceClassification(nn.Module):
    def __init__(self, bert_model, num_labels):
        super(BertForSequenceClassification, self).__init__()
        self.bert = bert_model
        self.dropout = nn.Dropout(bert_model.config.hidden_dropout_prob)
        self.classifier = nn.Linear(bert_model.config.hidden_size, num_labels)
    
    def forward(self, input_ids, attention_mask=None, token_type_ids=None):
        outputs = self.bert(input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids)
        pooled_output = outputs[1]
        logits = self.classifier(pooled_output)
        return logits

num_labels = 2
model = BertForSequenceClassification(bert_model, num_labels)

そうすると、以下のように出力層が加わっていることがわかります。

これでモデルの構築はおしまいです。

2.3.2 ファインチューニングの実行

先ほど構築したモデルを実行してみて、epochとlossの関係をグラフにしてみます。

まずまずの仕上がりでした。

3.終わりに

今回はBertのファインチューニングをやってみました。やってみると思った以上に時間がかかりました。
自然言語に関しては全くの素人ですので間違っていたりなどしましたらご指摘ください。
次回以降はもっと面白い記事を書けるように頑張ろうと思います!

Discussion