【Python】pytorchとBERTで文章分類をする方法

Python
スポンサーリンク

BERTを使えば簡単に文章分類をすることができます。

from torch import nn
from transformers import AutoModel

class CustomModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.bert = AutoModel.from_pretrained(MODEL_NAME) # "bert-base-uncased"
        self.head = nn.Linear(in_features = 768, out_features = 20) # クラス数
    
    def forward(self, input_ids, attention_mask, token_type_ids):
        outputs = self.bert(
            input_ids = input_ids,
            attention_mask = attention_mask,
            token_type_ids = token_type_ids
        )
        pooler_output = outputs.pooler_output # 768次元の埋め込み表現
        logits = self.head(pooler_output)
        return logits

しかしBERTは計算が重くGPUが必須です。
なので、Google Colaboratoryを使用するか、GPUを搭載したPCを自作する必要があります。
もっと軽い計算を求めている人はLightGBMを使った方法を試しましょう。

当ブログでは、もし私が機械学習初心者だったらどう勉強するかも解説しています。
>>コスパよく機械学習を勉強するロードマップ

データセット

fetch_20newsgroups

from sklearn.datasets import fetch_20newsgroups
import pandas as pd

train_data = fetch_20newsgroups(subset = 'train')
test_data = fetch_20newsgroups(subset = 'test')

train = pd.DataFrame({"text" : train_data["data"], "target" : train_data["target"]})
test = pd.DataFrame({"text" : test_data["data"], "target" : test_data["target"]})

train.head()

“fetch_20newsgroups”を使いましょう。
“text”に入っている文章から、20種類ある”target”を予測します。

print(train["text"].values[0])

このように各行の”text”に文章が入っています。

print(sorted(train["target"].unique()))
print(sorted(test["target"].unique()))

# ========== output ==========
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]

“target”は0 ~ 19までの数値データです。
つまり文章からカテゴリ(数値)を分類していきます。

Text Cleaning

import re
def cleaning(text):
    text = re.sub("\n", " ", text)           # 改行削除
    text = re.sub("[^A-Za-z0-9]", " ", text) # 記号削除
    text = re.sub("[' ']+", " ", text)       # スペース統一
    return text.lower()                      # 小文字で出力

text = train["text"].values[0]
print(text[:100])
print("")
text_cleaned = cleaning(text)
print(text_cleaned[:100])

# ========== output ==========
# From: lerxst@wam.umd.edu (where's my thing)
# Subject: WHAT car is this!?
# Nntp-Posting-Host: rac3.wam.

# from lerxst wam umd edu where s my thing subject what car is this nntp posting host rac3 wam umd edu

文章内には改行や特殊な文字が多いので、正規表現を使って単純化しました。
またこれから使用するBERTは小文字のみに対応しているので、小文字変換もしましょう。
※すべてのBERTモデルが小文字しか扱えないということではありません。

train["text_cleaned"] = train["text"].map(cleaning)
test["text_cleaned"] = test["text"].map(cleaning)
train.head()
クリーニングした文章

こんな感じで、改行がない小文字になっていればOKです。

Tokenizer

Tokenizer作成

#!pip install transformers
from transformers import AutoTokenizer

MODEL_NAME = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

“bert-base-uncased”という形式のtokenizerを作成しました。
機械学習モデルは数値としてデータを入れる必要があります。
なので、tokenizerを使って文章をベクトル化しましょう。

text = train["text_cleaned"].values[0]

encoded = tokenizer(text)
print(encoded.keys())

# ========== output ==========
# dict_keys(['input_ids', 'token_type_ids', 'attention_mask'])
辞書型データになっており、キーは以下の通りです。
・input_ids:単語をidに変換したもの
・attention_mask:使用するidは1, 使用しないidは0
・token_type_ids:1つ目の文章なら0, 2つ目の文章なら1

input_ids

input_ids = encoded["input_ids"]
print(len(input_ids))
print(input_ids[:10])

# ========== output ==========
# 154
# [101, 2013, 3393, 2099, 2595, 3367, 11333, 2213, 8529, 2094]

input_idsは、単語をトークンと呼ばれるidに変換したものです。
単語数が多いほどidの数も増えます。

print(text)
print()
print(tokenizer.decode(input_ids))
トークン化された文章

正しく変換されているかは、decodeで元に戻すとわかります。
[CLS]と[SEP]が追加されていますが、他は同じですね。
これで文字データを数値に変換することができたので、機械学習モデルに入れることができます。

attention_mask

attention_mask = encoded["attention_mask"]
print(len(attention_mask))
print(attention_mask[:10])
print(attention_mask[-10:])

# ========== output ==========
# 154
# [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
# [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

attention_maskは、学習時に使用するトークンと無視するトークンを識別するものです。
1なら使用、0なら無視されます。
今はすべて1で、その数はinput_idsと同じです。

例えば、後で解説するパディングで追加されたダミーのidは学習に不要なので、
attention_mask = 0になります。

token_type_ids

token_type_ids = encoded["token_type_ids"]
print(len(token_type_ids))
print(token_type_ids[:10])
print(token_type_ids[-10:])

# ========== output ==========
# 154
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

token_type_idsは、文章を2つ入力するときに使用します。
1つ目の文章にあるトークンは0、2つ目の文章にあれば1になります。
2つの文章をインプットしたい場合に活用されますが、今回は1つだけなので意味ありません。

例えば2つの文章を入れて、
その2つが同じカテゴリのものか二値分類するモデルを作るときに使用されます。

パディング

text1 = train["text_cleaned"].values[0]
text2 = train["text_cleaned"].values[1]

encoded = tokenizer(text1)
print(len(encoded["input_ids"]))

encoded = tokenizer(text2)
print(len(encoded["input_ids"]))

# ========== output ==========
# 154
# 171

ベクトル化されたidの数は、元の文章の長さに依存します。
なので、1つ目の文章と2つ目の文章とではidの数が異なります。

しかし、機械学習モデルを作る際は、全データの次元(列数)を統一する必要があります。
全文章の長さを見てみましょう。

import matplotlib.pyplot as plt
lens = []
for text in train["text_cleaned"].values:
    encoded = tokenizer.encode_plus(text.lower())
    lens.append(len(encoded["input_ids"]))
plt.hist(lens)
plt.show()

import numpy as np
lens = np.array(lens)
print(np.percentile(lens, 50))
print(np.percentile(lens, 75))

# ========== output ==========
# 233.0
# 368.0
文章長さのヒストグラム

最長だと20000を超える長さがありますが、中央値で233、75%で368です。
長すぎると計算が重いので、中央値に近い256を最大長さにして、
それよりも短い文章はパディングし、長い文章は切り捨てしましょう。

MAX_LEN = 256
text = train["text_cleaned"].values[0]
encoded = tokenizer(text, padding = "max_length", max_length = MAX_LEN, truncation = True)
print(len(encoded["input_ids"]))
print(encoded["input_ids"][-10:])
print(encoded["attention_mask"][:10])
print(encoded["attention_mask"][-10:])

print(tokenizer.decode(encoded["input_ids"]))

# ========== output ==========
# 256
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
・padding:パディングの方法。”max_length”にすると数値で指定できる。
・max_length:最大のデータ長さ。
・truncation:Trueにするとmax_lengthを超えた分はカットされる。

実行してみるとデータの長さが256になっています。

256までに足りない分は[PAD]トークンで埋められています。
そしてこの部分のattention_maskは0になっているので、機械学習モデルは無視してくれます。

Dataset, DataLoader

import torch
from torch.utils.data import Dataset, DataLoader

class CustomData(Dataset):
    def __init__(self, data, tokenizer):
        self.data = data
        self.tokenizer = tokenizer
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):
        text = self.data["text_cleaned"].values[idx]
        encoded = self.tokenizer(
            text, padding = "max_length", max_length = MAX_LEN, truncation = True)
        input_ids = torch.tensor(
            encoded["input_ids"], dtype = torch.long)
        attention_mask = torch.tensor(
            encoded["attention_mask"], dtype = torch.long)
        token_type_ids = torch.tensor(
            encoded["token_type_ids"], dtype = torch.long)
        
        label = self.data["target"].values[idx]
        label =  torch.tensor(label, dtype = torch.float)
        return input_ids, attention_mask, token_type_ids, label
    
train_data = CustomData(train, tokenizer)
test_data = CustomData(test, tokenizer)
train_dl = DataLoader(train_data, batch_size = 16, shuffle = True, drop_last = True)
test_dl = DataLoader(train_data, batch_size = 32, shuffle = False, drop_last = False)

batch = next(iter(train_dl))
print(len(batch))
print(batch[0].shape)
print(batch[1].shape)
print(batch[2].shape)
print(batch[3].shape)

# ========== output ==========
# 4
# torch.Size([16, 256])
# torch.Size([16, 256])
# torch.Size([16, 256])
# torch.Size([16])

input_ids, attention_mask, token_type_idsを作り、出力します。
pytorchで使うtensor型にしておきましょう。
出力のサイズは(batch_size, MAX_LEN)になっています。

Datasetの作り方や、pytorch自体の使い方は以下の記事を参考にしてください。
【関連記事】pytorchで機械学習モデルを作成する方法

モデル作成

from torch import nn
from transformers import AutoModel

class CustomModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.bert = AutoModel.from_pretrained(MODEL_NAME) # "bert-base-uncased"
        self.head = nn.Linear(in_features = 768, out_features = 20) # クラス数
    
    def forward(self, input_ids, attention_mask, token_type_ids):
        outputs = self.bert(
            input_ids = input_ids,
            attention_mask = attention_mask,
            token_type_ids = token_type_ids
        )
        pooler_output = outputs.pooler_output # 768次元の埋め込み表現
        logits = self.head(pooler_output)
        return logits

TokenizerとModelとで同じ種類のBERTにしておきましょう。
BERTの出力には複数の形式があります。今回は”pooler_output”にしました。
この出力では768個のデータがあるので、Linear層で受け取ってラベル数20にしましょう。

学習

import matplotlib.pyplot as plt
from tqdm import tqdm

# モデル呼び出し
model = CustomModel().cuda()

# 最適化手法
optimizer = torch.optim.Adam(model.parameters(), lr = 1e-5)

# 損失関数
criterion = nn.CrossEntropyLoss()

# 学習ログを保存する変数
history = {"train": [], "test": []}

# epochの数だけ学習を繰り返す
for epoch in range(5):

    # 学習
    model.train()
    train_loss = 0
    for batch in tqdm(train_dl):
        optimizer.zero_grad()
        input_ids = batch[0].cuda()
        attention_mask = batch[1].cuda()
        token_type_ids = batch[2].cuda()
        label = batch[3].cuda()
        logits = model(input_ids, attention_mask, token_type_ids)
        loss = criterion(logits, label) # 誤差計算
        loss.backward()                 # 誤差伝播
        optimizer.step()                # パラメータ更新
        train_loss += loss.item()
    train_loss /= len(train_dl)

    # 検証
    model.eval()
    test_loss = 0
    with torch.no_grad(): # パラメータ更新をしない
        for batch in tqdm(test_dl):
            input_ids = batch[0].cuda()
            attention_mask = batch[1].cuda()
            token_type_ids = batch[2].cuda()
            label = batch[3].cuda()
            logits = model(input_ids, attention_mask, token_type_ids)
            loss = criterion(logits, label)
            test_loss += loss.item()
    test_loss /= len(test_dl)

    # ログ保存
    history["train"].append(train_loss)
    history["test"].append(test_loss)

plt.plot(history["train"], label = "train")
plt.plot(history["test"], label = "test")
plt.legend()
plt.show()
学習経過

学習率が高いとうまくlossが下がらないので、1e-5にしました。

予測

import numpy as np

preds = []
model.eval()
with torch.no_grad():
    for batch in tqdm(test_dl):
        input_ids = batch[0].cuda()
        attention_mask = batch[1].cuda()
        token_type_ids = batch[2].cuda()
        logits = model(input_ids, attention_mask, token_type_ids)
        preds.append(nn.Softmax(dim = 1)(logits).cpu().numpy())
preds = np.concatenate(preds, axis = 0)

予測は検証時と同じことをして、モデルの出力を取り出しておけばOKです。
Softmaxを列方向にかけて、各ラベルに該当する確率にしましょう。

print(preds[0])
print(test["target"].values[0])

# ========== output ==========
# [1.6451158e-04 1.8229702e-04 1.5335761e-04 2.8265754e-04 1.5762530e-03
#  7.1881886e-04 2.1571817e-03 9.8252732e-01 6.8850527e-03 3.0731558e-04
#  2.6096587e-04 1.7438488e-04 1.6679204e-03 1.9576667e-04 7.0855254e-04
#  7.5375231e-04 2.9293401e-04 1.6689999e-04 6.9674669e-04 1.2726044e-04]
# 7

このように20ラベルそれぞれの確立になっています。

pred_labels = preds.argmax(axis = 1)

print(pred_labels[:5])
print(test["target"].values[:5])

# ========== output ==========
# [ 7  5  0  0 19]
# [ 7  5  0 17 19]

argmaxを列方向に適用し、最も確立の大きい列番号を予測ラベルとしました。
だいたいあっていそうですね。

正解率を計算してみましょう。

from sklearn.metrics import accuracy_score

print(accuracy_score(test["target"].values, pred_labels))

# ========== output ==========
# 0.8574083908656399

85.7%ですね。ちなみにLightGBMで作ったモデルの精度は66.3%でした。
BERTのほうが精度が高いモデルを作りやすいですが、計算が重いことが難点です。

まとめ

今回はBERTの使い方を解説しました。
精度よりも計算の軽さを優先したいなら、CPUでLightGBMを使う方法をおすすめします。
BERTを使いたいなら、Google Colaboratoryを使うか、GPUを搭載したPCを自作しましょう。

なんか適当に独学してるだけで、

どうやって勉強を進めたらいいかわからんな。。。

と悩んでいる人向けに、
もし私が初心者ならどう勉強するかを解説しているので、参考にどうぞ。
>>コスパよく初心者が機械学習を勉強する方法|ロードマップ

コメント

タイトルとURLをコピーしました