Pytorchチュートリアルのテキスト分類 ~ torchtextとEmbeddingBag ~

スポンサーリンク

 
世界に舞う無数のとめどない言葉。これらは生まれては、区別の無い大きな箱に積み重なって忘れられていく。まるで情報過多なのに何も残っていない人間の記憶のように。しかし、ひとたび境界線ができると、情報は区別され、整理される。そして、ヒトの理解はより鮮明になる。言葉も同様に。という、前置きはここまでにしておいて、今回はテキストの分類モデルの構築をやってみようと思います。


テキストの分類を行うためには、ディープラーニングのライブラリを使うのが常套手段です。今回は数あるディープラーニングのライブラリの中でも注目度が高いPytorchを使います。というのも、今やディープラーニングのライブラリはTensorflow/kerasかPytorchかという2強であり、私はこれまでkeras頼みだったので、この期にものにしたいと思ったからです。


Pytorchには素晴らしいチュートリアルがあり*1、この中のText Classification with Torch Textを自分なりの解説を付けながら実装してみようと思います。今回のモデルは、使用するデータ、ディープラーニングの構築など主要な処理はPytorchで完結します。



プロセスの全体像


今回の実行プロセスは以下の通りです。非常に一般的な機械学習モデルの構築プロセスですね。


f:id:dskomei:20191226122427p:plain:w600


学習データの作成


これからPytorchを使ってテキスト分類のモデルを作っていきますが、ディープラーニングのモデルはデータが大事であり、全てはデータから始まります。なので、まずは今回使用するデータの取得について書きます。


使用データの取得


今回使用するデータはカテゴリ別ニュース*2です。これはtorchtextを使ってワンライナーで簡単に取得できます。このデータは、各ニュースのテキストにカテゴリがついており、カテゴリの種類はは4つあります。4つのカテゴリは「World」「Sports」「Business」「Sci/Tec」です。以下に、今回使用するモジュールのインポートとパスの設定とデータ取得の処理を書きます。


モジュールのインストール


import os
from pathlib import Path
import time
from dfply import *

import torch
import torchtext
import torch.nn as nn
import torch.nn.functional as F
from torchtext.datasets import text_classification
from torch.utils.data import DataLoader
from torch.utils.data.dataset import random_split


パスの設定


ata_dir_path = Path('data')
if not data_dir_path.exists():
    data_dir_path.mkdir(parents=True)


データの取得


以下のコードでは、torchtextで用意されているデータの中からAG_NEWSのデータを指定したディレクトリにダウンロードしています。その際に、学習用データとテスト用データを返しており、train_datasetとtest_datasetにそれぞれを格納しています。データを得るのは非常に簡単ですね。

train_dataset, test_dataset = text_classification.DATASETS['AG_NEWS'](
    root=data_dir_path,
    ngrams=1,
    vocab=None
)



取得したデータの確認


上記のコードでデータを簡単に取得することができました。それでは、獲得したデータを確認してみましょう。

for label, text in train_dataset[:3]:
    print(label, text)

f:id:dskomei:20191226003359p:plain


上の結果を見ると、1つのレコードに、ニュースのカテゴリ番号と本文の各単語をインデックス化したリストのTensor型があることがわかります。上記の結果では、カテゴリ番号が2の『Business』が3つ表示されています。しかし、本文はインデックス化されているので、このデータがカテゴリ通りなのかがわかりません。そこでインデックスを各単語に逆変換します。


単語とインデックス


上記のコードで得たデータは最初からインデックス化されていましたが、通常はテキストの単語一つ一つにユニークな番号を振ってインデックス化し、これをもとにテキスト全体の各単語をインデックスに置き換えていきます。つまり、単語とインデックスの対応データがあれば、どちらにも変換可能です。torchtextのDatasetにはその単語とインデックスの対応データが用意されています。

vocabs = list(train_dataset.get_vocab().stoi.items())
for voca in vocabs[:10]:
    print(voca)

f:id:dskomei:20191226000716p:plain


上記のコードでは、torchtextのデータからget_vocab関数によって単語とインデックスのデータをまず取得しています。そして、単語とインデックスの対応辞書をリスト型に変換し、リストの最初から10件目までを表示しています。このインデックスは単語の出現頻度が多い順であり、上記の結果は出現頻度が多い10件を表示しています。ただ、これでは単語列をインデックス列に変換することはできますが、インデックス列を単語列に変換することができません。そこで、インデックスをキーとして、単語を値とする辞書を作り、これを使って変換します。

vocabs_from_index_to_char = dict(
    [(index, char) for char, index in train_dataset.get_vocab().stoi.items()]
)
for voca in list(vocabs_from_index_to_char.items())[:10]:
    print(voca)

f:id:dskomei:20191226001428p:plain:w700


インデックスをキー、単語を値とした辞書ができたので、これを使ってインデックス列から単語列に変換し、カテゴリ番号とテキストが合致しているかを確かめます。

for label, text in train_dataset[:3]:
    print(label, ' '.join(list(map(lambda x: vocabs_from_index_to_char[int(x)], text))))

f:id:dskomei:20191226003727p:plain


上記の結果を見ると、カテゴリ「Business」と内容が合致していることがわかります。わざわざ確認作業をして冗長なような気もしますが、テキストの学習データの作成において、単語列➪インデックス列 or インデックス列➪単語列の変換は必ず必要なので、書いてみました。


torchtextではないテキストをtorchtextに落とし込んで学習データに変換する処理は、また今度にしようと思いますので、あしからず。


EmbeddingBagを使った予測モデルの構築


ここまでの話で学習データの作成ができたので、ニュースのカテゴリを予測するモデルを構築していきます。今回構築するモデルはEmbeddingBagと出力層だけのお手頃なものです。とはいっても、EmbeddingBagを聞き慣れていない方もいると思うので、EmbeddingBagについて見ていきますが、今回のモデルの全体像を先に示しておきます。何事も全体像を掴んでおくことで、理解しやすくなります。


f:id:dskomei:20191226025649p:plain:w500


モデルの定義


上の図でモデルの全体像がわかります。この全体像のままモデルのクラス定義を行ってしまいましょう。まだEmbeddingBagが何者かわからない方もいると思いますが、ひとまず入力データのテキストを受けて次の層につなぐものとして受け流してください。


モデルのクラス定義


class TextSentiment(nn.Module):
    
    def __init__(self, vocab_size, embed_dim, num_class):
        super().__init__()
        self.embedding = nn.EmbeddingBag(
            vocab_size,
            embed_dim,
            sparse=True
        )
        self.fc = nn.Linear(embed_dim, num_class)
        self.init_weights()
    
    
    # パラメータの初期化を行う関数
    def init_weights(self):
        initrange = 0.5
        self.embedding.weight.data.uniform_(-initrange, initrange)
        self.fc.weight.data.uniform_(-initrange, initrange)
        self.fc.bias.data.zero_()
    
    
    # モデルの予測時に実行する関数
    def forward(self, text, offsets):
        embeded = self.embedding(text, offsets)
        return self.fc(embeded)


Pytorchにおけるモデル構築では、nn.Moduleを継承することで基本的な処理を自前で書かずに済みます。更に必要な場合はカスタマイズします。今回はモデルの初期化と予測時の各層の構成を書けば完成します。

  • 初期化の関数にEmbeddingBag層とその次の出力層を定義し、それぞれの層のパラメータに指定した範囲のランダム値を割り当てる(__init__とinit_weights関数)
  • 予測時の処理として、入力データをEmbeddingBag層に入れ、その出力を次の層の入力とし、最後に出力結果を返す(forward関数)


以上でモデルの定義は終了です。今回扱うモデルは簡易なこともあり、モデル定義のコード量は少なくてわかりやすいです。モデルの定義ができたので、このままモデルのインスタンスを作ってしまいましょう。まずは、モデルの形を決めるパラメータを定義してしまいます。


パラメータの設定


BATCH_SIZE = 16
EMBED_DIM = 32
VOCAB_SIZE = len(train_dataset.get_vocab())
NUM_CLASS = len(train_dataset.get_labels())
print('Vocab size : {}, Num class : {}'.format(VOCAB_SIZE, NUM_CLASS))

f:id:dskomei:20191226095426p:plain


モデルのインスタンスの作成


model = TextSentiment(
    VOCAB_SIZE,
    EMBED_DIM,
    NUM_CLASS
)


上のコードでモデルのインスタンスができたので、これを学習させればニュースカテゴリ予測モデルが完成します。その前に、先延ばしにしていたEmbeddingBagについて次に見ていきます。


EmbeddingBag


ここまでで、モデル定義のクラスが完成しました。しかし、クラスのコードを書いたはいいものの、EmbeddingBagに関しては、脳内メーカーで見たらはてなマークで埋め尽くされるぐらいわからないことだらけです。しかし、通常のEmbeddingはご存じの方も多く、Embeddingから見ていくことでイメージしやすいと思います。


Embedding


Embeddingは、縦の長さを語彙数、横の長さを指定したベクトルの長さ(出力数)とした行列であり、各行一つ一つが各単語の重みベクトルになります。インデックスの数字は各単語の名札みたいなものなので、この数字の大小に意味はありません。このインデックスに応じた重みベクトルをモデルの学習時に更新していくことで、単語の距離を反映するようになっていきます。その結果、単語の違いを反映した行列になり、モデルの精度向上に役立ちます。


以下の図は入力データ [ [1,2,3], [2, 3] ] をEmbedding層へ代入した場合の例です。まず、入力データの各リストの長さを揃えて(0で埋めて)からEmbeddingに代入します。代入した結果は、各インデックスのベクトルがそのまま抜き出されて、1次元増えた行列になります。


f:id:dskomei:20191226042601p:plain:w600


EmbeddingBag


では、同じ入力データ [ [1,2,3], [2, 3] ] に対するEmbeddingBagの処理がどうなるかを見てみましょう。

f:id:dskomei:20191226043407p:plain:w600


EmbeddingとEmbeddingBagの違いは2点あります。

  1. EmbeddingBagにおいて、各レコードの長さは揃えなくてもよい
  2. 出力はmodeで指定した統計処理をした結果


EmbeddingBagの定義によってできた行列の形はEmbeddingと同じです。しかし、入力では、1次元になっており、各レコードの開始位置のリストも引数としています。こうすることで、メモリの節約になります。というのも、Embeddingのときには入力データのレコードの長さを揃えなければいけなかったので、一つだけでも長いレコードがあると入力データの行列は大きくなってしまいます。しかし、EmbeddingBagに代入するデータは1次元であり、各レコードの長さを揃えなくても良いので、メモリを無駄に食いつぶさなくても良くなります。しかし、ただ1次元にしただけだとレコードの区切りがわからないので、開始位置のリストが必要です。

 
更に、今回はmodeでsumをしているため、EmbeddingBagの指定されたインデックスの各ベクトルの重みの合計が出力となっています。今回は単語の順番を考えなくても良いので、Embeddingにおいて統計処理を行った結果を次の層に送っています。これが、翻訳のような単語の順番が関係するモデルを構築するときは、EmbeddingBagは使えません。


以上のことから、EmbeddingBagは、テキストデータを入力とするモデルに対して、文の時系列の情報がいらないときに、Embeddingよりも軽量なモデルを構築したい場合に適しています。


予測モデルの学習


ここまでの処理でモデルの作成が終わったので、実際にモデルを学習していきます。そのための関数を先に記述します。


学習に必要な関数の定義


与えられたデータでモデルを学習する関数を記述します。

def train_func(model, data, batch_size, optimizer, collate_fn, criterion, scheduler):
    
    train_loss = 0
    train_acc = 0
    data = DataLoader(
        data,
        batch_size=batch_size,
        shuffle=True,
        collate_fn=collate_fn
    )
    
    for i, (text, offsets, label) in enumerate(data):
        optimizer.zero_grad()
        output = model(text, offsets)
        loss = criterion(output, label)
        train_loss += loss.item()
        loss.backward()
        optimizer.step()
        train_acc += (output.argmax(1) == label).sum().item()
        
    scheduler.step()
    return train_loss / len(data), train_acc / len(data)


流れとしては複雑なことはなく、以下の処理が行われています。

  1. データを指定したサイズで分割(ミニバッチ化)
  2. ミニバッチの1つ1つでモデルの学習を行う
    1. パラメータの偏微分した結果を初期化する
    2. インデックス化した1次元のテキストデータとテキストの開始位置のリストを引数として、ニュースカテゴリの予測を行う
    3. 予測した結果と目的値から損失値を求める
    4. 損失値を減少させるための各変数の偏微分値を求める
    5. 各パラメータの値を偏微分値を使って更新


上記の関数に適切な引数を与えることで、モデルの学習を行えます。ただ、お気づきな方もいるかと思いますが、データのミニバッチ化のときにDataLoaderの引数として「collate_fn」という不穏なものを渡しています。これは何かというと、引数として関数を受け取り、ミニバッチ化したデータにこの関数の処理が行われます。今回は以下の処理を行っています。

  1. 各データをカテゴリ番号のリストと本文のリストに分離
  2. テキストデータの1次元化において各テキストの開始位置のリストを作成
  3. テキストデータの1次元化(torch.cat関数)
  4. 予測モデルでデータを扱えるようにするためデータをテンソル化


以上の処理を以下のコードで関数化しています。

def generate_batch(batch):

    labels = torch.tensor([entry[0] for entry in batch])
    texts = [entry[1] for entry in batch]
    
    # 1次元のテキストリストにおいてテキストの開始位置を保持しておく変数
    offsets = [0] + [len(entry) for entry in texts]
    offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
    
    texts = torch.cat(texts)
    return texts, offsets, labels


これでモデルを学習するのに必要な処理を関数で書くことができました。モデルの精度の観点では、学習データの正答率よりは学習に使用していないデータにおける正答率の方が大事なので、学習処理と同様に確認用のデータにおける正答率の算出を記述します。

def test(model, data, batch_size, collate_fn, criterion):
    
    loss = 0
    acc = 0
    data = DataLoader(
        data, 
        batch_size=batch_size,
        collate_fn=collate_fn
    )
    
    for text, offsets, label in data:
        with torch.no_grad():
            output = model(text, offsets)
            loss = criterion(output, label)
            loss += loss.item()
            acc += (output.argmax(1) == label).sum().item()
            
    return loss / len(data), acc / len(data)


上記のコードはパラメータの変更処理がないので、学習のときの関数よりは簡潔ですね。
以上で学習に必要関数の定義は終了です。それでは、実際にモデルを学習していきます。


予測モデルの学習


以下では、指定した回数繰り返す学習処理を記述していますが、上記で重要な処理を関数化しているので、関数を呼び出すだけになっています。学習が終わったあとに、正答率と損失関数値の推移が確認できるように、ループ1回ごとに正答率と損失関数値をリストに格納しています。

N_EPOCHS = 50
min_valid_loss = float('inf')

criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=4.0)
scheduler = torch.optim.lr_scheduler.StepLR(
    optimizer,
    1,
    gamma=0.9
)

train_len = int(len(train_dataset) * 0.95)
sub_train_, sub_valid_ = random_split(
    train_dataset,
    [train_len, len(train_dataset) - train_len]
)

train_acc_list = []
train_loss_list = []
valid_acc_list = []
valid_loss_list = []
for epoch in range(N_EPOCHS):
    
    start_time = time.time()
    train_loss, train_acc = train_func(
        model=model,
        data_=sub_train_,
        batch_size=BATCH_SIZE,
        optimizer=optimizer,
        collate_fn=generate_batch,
        criterion=criterion,
        scheduler=scheduler
    )
    valid_loss, valid_acc = test(
        model=model,
        data_=sub_valid_,
        batch_size=BATCH_SIZE,
        collate_fn=generate_batch,
        criterion=criterion
    )
    
    train_loss_list.append(train_loss)
    train_acc_list.append(train_acc)
    valid_loss_list.append(valid_loss)
    valid_acc_list.append(valid_acc)
    
    secs = int(time.time() - start_time)
    mins = secs / 60
    secs = secs % 60
    
    print('Epoch : {:.0f}, time in {:.0f} minutes, {:.0f} seconds'.format(epoch+1, mins, secs))
    print('\tLoss: {:.4f}(train)\t\tAcc: {:.1f}%(train)'.format(train_loss, train_acc*100))
    print('\tLoss: {:.4f}(valid)\t\tAcc: {:.1f}%(valid)'.format(valid_loss, valid_acc*100))
    
    
result = pd.DataFrame({
    'epoch' : np.arange(1, len(train_acc_list)+1),
    'train_acc' : train_acc_list,
    'valid_acc' : valid_acc_list,
    'train_loss' : train_loss_list,
    'valid_loss' : valid_loss_list
})
result.to_csv(result_dir_path.joinpath('model_result.csv'), index=False)
print(result)


結果は以下のグラフのようになりました。


f:id:dskomei:20191226110926p:plain:w600


明らかに学歴は高いのに仕事ができないタイプのような過学習状態ですね。まぁ、今回はPytorchでテキスト分類を行うためのチュートリアルという立ち位置であり、過学習対策をあまりしていないので良しとしましょう。


学習済みモデルで予測


ここまでのコードで今回作りたいニュースカテゴリ予測モデルができたので、テストデータを使って精度を測定してみましょう。

テストデータで確認


test_loss, test_acc = test(
    model=model,
    data_=test_dataset,
    batch_size=BATCH_SIZE,
    collate_fn=generate_batch,
    criterion=criterion
)
print('Loss: {:.4f}(test)\t\tAcc: {:.1f}%(test)'.format(test_loss, test_acc*100))

f:id:dskomei:20191226115700p:plain


結果は89.1%でした。これはすごい精度の高いモデルというわけではないですが、最小限の機能でモデルを作ったので、モデルの簡潔さという観点からはまぁまぁでしょう。


テキストで学習済みモデルの予測結果を確認


ニュースのテキスト文字列を与えた場合の学習済みモデルの予測結果を確認してみましょう。以下のニュースは「Golf」の内容なので、出力結果のカテゴリは『Sports』であれば正しいです。

from torchtext.data.utils import get_tokenizer

ag_news_label = {1 : "World",
                 2 : "Sports",
                 3 : "Business",
                 4 : "Sci/Tec"}

def predict(text, model, vocab, ngrams):
    tokenizer = get_tokenizer("basic_english")
    with torch.no_grad():
        text = torch.tensor([vocab[token]
                            for token in tokenizer(text)])
        output = model(text, torch.tensor([0]))
        return output.argmax(1).item() + 1

ex_text_str = "MEMPHIS, Tenn. – Four days ago, Jon Rahm was \
    enduring the season’s worst weather conditions on Sunday at The \
    Open on his way to a closing 75 at Royal Portrush, which \
    considering the wind and the rain was a respectable showing. \
    Thursday’s first round at the WGC-FedEx St. Jude Invitational \
    was another story. With temperatures in the mid-80s and hardly any \
    wind, the Spaniard was 13 strokes better in a flawless round. \
    Thanks to his best putting performance on the PGA Tour, Rahm \
    finished with an 8-under 62 for a three-stroke lead, which \
    was even more impressive considering he’d never played the \
    front nine at TPC Southwind."

vocab = train_dataset.get_vocab()

print("This is a %s news" %ag_news_label[predict(ex_text_str, model, vocab, 2)])

f:id:dskomei:20191226120610p:plain


正しく予測できたことがわかります。


参考文献


自然言語処理のDeep Learning本といえばこちらの本が非常にわかりやすかったです。word2vecやLSTMなどの自然言語処理の主要なアルゴリズムはもちろんなこと、各アルゴリズムが数式でも抑えられているので、深い理解ができます。