NLP を学ぶ - 4

はじめに

今回は前回https://nakamrnk.hatenablog.com/entry/2020/04/03/172314に引き続きNLP関連のデータセットに 触れてみる。今回はSNLI(含意判定)とMulti30K(翻訳)のデータセットについて実際にモデルを学習してみる。

SNLI

SNLI(https://nlp.stanford.edu/projects/snli/)は含意(entailment)判定に対するデータセットである。 画像のキャプションを元に作られている。 含意判定は前提文章、仮説文章を入力として前提の元で仮説が成り立つかを判定する問題である1。 以下のチュートリアルを参考にして実装を行った。
https://github.com/pytorch/examples/tree/master/snli
環境はGoogle Colaboratory で行った。

前処理・解析

読み込み

# 前提文、仮説文(inputs)とラベル(answers)のFieldを定義
inputs = data.Field(lower=True, tokenize='spacy')
answers = data.Field(sequential=False)
train, valid, test = datasets.SNLI.splits(inputs, answers)

np.random.seed(0)
rows = []
for dataset in (train, valid, test):
  i = np.random.randint(len(dataset))
  rows.append((dataset[i].label, " ".join(dataset[i].premise),  " ".join(dataset[i].hypothesis)))

sample_df = pd.DataFrame(rows, columns=["判定",  "前提", "仮説"]).set_index("判定")
print(sample_df.to_markdown())

Field(inputs)は前提文、仮説文の両方を処理している。
解答のField(answers)ではsequential=Falseという設定でtokenizeしないようにしている。

データサンプル

判定 前提 仮説
entailment several people walk through a crowded asian city . there are several people in this photo , and they are all outside .
neutral a man and a woman in blue jog on the sidewalk while a man stretches on a park bench . a married couple jogging while a man stretches on a park bench .
contradiction several adults look on while a young girl plays in a ball pit . a young girl is sitting in her chair .

人間でも少し考えなければ理解できない関係を解答する必要がある。

統計量

コード

datasets = [train, valid, test]
rows = []
columns = ["統計量", "学習", "評価", "テスト"]
def get_vocab_size(dataset):
  inputs.build_vocab(dataset)
  return len(inputs.vocab)

# 各データセットの統計量
rows.append(["前提・仮説ペア数"] + list(map(lambda x:len(x), datasets)))
rows.append(["前提文単語数"] + list(map(lambda x:sum([len(s.premise) for s in x]), datasets)))
rows.append(["前提文平均長"] + list(map(lambda x, y:int(y/x), rows[0][1:], rows[1][1:])))
rows.append(["仮説文単語数"] + list(map(lambda x:sum([len(s.hypothesis) for s in x]), datasets)))
rows.append(["仮説文平均長"] + list(map(lambda x, y:int(y/x), rows[0][1:], rows[3][1:])))
rows.append(["語彙数"] + list(map(get_vocab_size, datasets)))

stat_df = pd.DataFrame(rows, columns=columns).set_index("統計量")
print(stat_df.to_markdown())

統計量 学習 評価 テスト
前提・仮説ペア数 549367 9842 9824
前提文単語数 7.7703e+06 150890 150369
前提文平均長 14 15 15
仮説文単語数 4.54604e+06 82428 81893
仮説文平均長 8 8 8
語彙数 33672 6317 6420

学習データは50万、評価、テストデータは1万程度のデータ数。
1文あたりの平均長は前提文で15, 仮説文で8程度と短い文章が多い。
また、仮説文のほうが前提文よりも全体的に短いことも分かる。

ラベル

label 学習 評価 テスト
contradiction 183187 3278 3237
entailment 183416 3329 3368
neutral 182764 3235 3219

ラベルはentailment(仮説は正しい), contradiction(仮説は間違っている), neutral(どちらとも言えない)がほぼ均等である。

DataLoader構築

# vocabulary, DataLoader構築
batch_size = 128
device = "cuda" if torch.cuda.is_available() else "cpu"

inputs.build_vocab(train, dev, test)
answers.build_vocab(train)

train_iter, dev_iter, test_iter = data.BucketIterator.splits(
            (train, dev, test), batch_size=batch_size, device=device)

モデル構築

チュートリアルのモデルSNLIClassifier2ではforwardで以下のような処理を行っている。

  1. 前提文、仮説文を単語単位でembeddingする(embedding matrixは共有している)。
  2. 1.でembeddingした前提文、仮説文それぞれを LSTMを用いたEncodeに通す(Encoderも共有している)。
  3. EncodeしたベクトルをconcatしてMLP(4層)で分類を行う。

結果

学習曲線

f:id:nakamrnk:20200404084723p:plain

train accuracyの学習曲線が不自然なステップ増加を見せているが、これは50Iterごとのそのエポックにおける累積正解数をもとにaccuracyを計算しているためであり、エポックの開始時に過去の累積値がリセットされ表示上のaccuracyは増加する。
validationは500Iterごとに全データを使って評価しているためそのような不自然な増加はない。
10000Iter程度(2エポックと少し)でValidation Accuracyはほぼ横ばい(0.7程度)となっている。

最終エポックモデルにおける評価結果は以下のようになっている。
Test Accuracy : 0.717
混同行列

正解\予測 contradiction entailment neutral
contradiction 2276 375 586
entailment 284 2549 535
neutral 479 521 2219

ややcontradction - etailment間の予測と比較するとneutral関連の誤判定が多い。
仮説が正しいかどうかの判定は文中の類義語や否定表現の有無などからある程度はできるような気がするが、前提文章と仮説文章が無関係かどうかの判断は確かに難しそうである。

ちなみにSNLIのリーダーボードは以下で公開されている。
https://paperswithcode.com/sota/natural-language-inference-on-snli
2020/4/4時点ではSemBERTと呼ばれる手法がtest accuracy 0.919を達成している。
LSTMでも test accuracy 0.7763まではでるようなので今回の検証は調整不足である可能性がある。

Multi30Kデータセット

Multi30KデータセットはFlickr30K4という英語の画像キャプションデータセットをドイツ語などの別言語に翻訳したデータセットである5
このデータセットを用いて機械翻訳に触れる。
pytorchのチュートリアルを参考にした。
https://pytorch.org/tutorials/beginner/torchtext_translation_tutorial.html
環境はGoogle Colaboratory 。

前処理・解析

前処理コード

#spacy の英語モデルをダウンロード
!python -m spacy download en

#spacy のドイツ語モデルをダウンロード
!python -m spacy download de

from torchtext.datasets import Multi30k
from torchtext.data import Field, BucketIterator

SRC = Field(tokenize = "spacy",
            tokenizer_language="de",
            init_token = '<sos>',
            eos_token = '<eos>',
            lower = True)

TRG = Field(tokenize = "spacy",
            tokenizer_language="en",
            init_token = '<sos>',
            eos_token = '<eos>',
            lower = True)

train_data, valid_data, test_data = Multi30k.splits(exts = ('.de', '.en'),
                                                    fields = (SRC, TRG))

前処理では以下の処理を行っている。

  1. spacyでtokenize
  2. 文章の開始文字としてを設定
  3. 文章の終了文字としてを設定
  4. 大文字を小文字に変換

vocabulary構築・統計量計算コード

# vocabulary構築
SRC.build_vocab(train_data, min_freq = 2)
TRG.build_vocab(train_data, min_freq = 2)


# 統計量計算
import pandas as pd
datasets = [train_data, valid_data, test_data]
rows = []
columns = ["統計量", "学習", "評価", "テスト"]

rows.append(["文章数"] + list(map(lambda x:len(x), datasets)))
rows.append(["ドイツ語 単語数"] + list(map(lambda x:sum([len(s.src) for s in x]), datasets)))
rows.append(["英語語 単語数"] + list(map(lambda x:sum([len(s.trg) for s in x]), datasets)))
rows.append(["ドイツ語 学習データ語彙数"] + list(map(lambda x:len(x.fields["src"].vocab), datasets)))
rows.append(["英語 学習データ語彙数"] + list(map(lambda x:len(x.fields["trg"].vocab), datasets)))

stat_df = pd.DataFrame(rows, columns=columns).set_index("統計量")
print(stat_df.to_markdown())

統計量 学習 評価 テスト
文章数 29000 1014 1000
ドイツ語 単語数 360751 12808 12102
英語語 単語数 380186 13426 13058
ドイツ語 学習データ語彙数 7855 - -
英語 学習データ語彙数 5893 - -

名前の通り約30000個の文章が存在する。
ドイツ語のほうが全体の単語数がやや少ないが語彙数は多いので1単語あたりの出現率が少ない。

Iterator 構築コード

import torch

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

BATCH_SIZE = 128

train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
    (train_data, valid_data, test_data),
    batch_size = BATCH_SIZE,
    device = device)

モデル構築

モデル

import random
from typing import Tuple

import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch import Tensor


class Encoder(nn.Module):
    def __init__(self,
                 input_dim: int,
                 emb_dim: int,
                 enc_hid_dim: int,
                 dec_hid_dim: int,
                 dropout: float):
        super().__init__()

        self.input_dim = input_dim
        self.emb_dim = emb_dim
        self.enc_hid_dim = enc_hid_dim
        self.dec_hid_dim = dec_hid_dim
        self.dropout = dropout

        self.embedding = nn.Embedding(input_dim, emb_dim)

        self.rnn = nn.GRU(emb_dim, enc_hid_dim, bidirectional = True)

        self.fc = nn.Linear(enc_hid_dim * 2, dec_hid_dim)

        self.dropout = nn.Dropout(dropout)

    def forward(self,
                src: Tensor) -> Tuple[Tensor]:

        embedded = self.dropout(self.embedding(src))

        outputs, hidden = self.rnn(embedded)

        hidden = torch.tanh(self.fc(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim = 1)))

        return outputs, hidden


class Attention(nn.Module):
    def __init__(self,
                 enc_hid_dim: int,
                 dec_hid_dim: int,
                 attn_dim: int):
        super().__init__()

        self.enc_hid_dim = enc_hid_dim
        self.dec_hid_dim = dec_hid_dim

        self.attn_in = (enc_hid_dim * 2) + dec_hid_dim

        self.attn = nn.Linear(self.attn_in, attn_dim)

    def forward(self,
                decoder_hidden: Tensor,
                encoder_outputs: Tensor) -> Tensor:

        src_len = encoder_outputs.shape[0]

        repeated_decoder_hidden = decoder_hidden.unsqueeze(1).repeat(1, src_len, 1)

        encoder_outputs = encoder_outputs.permute(1, 0, 2)

        energy = torch.tanh(self.attn(torch.cat((
            repeated_decoder_hidden,
            encoder_outputs),
            dim = 2)))

        attention = torch.sum(energy, dim=2)

        return F.softmax(attention, dim=1)


class Decoder(nn.Module):
    def __init__(self,
                 output_dim: int,
                 emb_dim: int,
                 enc_hid_dim: int,
                 dec_hid_dim: int,
                 dropout: int,
                 attention: nn.Module):
        super().__init__()

        self.emb_dim = emb_dim
        self.enc_hid_dim = enc_hid_dim
        self.dec_hid_dim = dec_hid_dim
        self.output_dim = output_dim
        self.dropout = dropout
        self.attention = attention

        self.embedding = nn.Embedding(output_dim, emb_dim)

        self.rnn = nn.GRU((enc_hid_dim * 2) + emb_dim, dec_hid_dim)

        self.out = nn.Linear(self.attention.attn_in + emb_dim, output_dim)

        self.dropout = nn.Dropout(dropout)


    def _weighted_encoder_rep(self,
                              decoder_hidden: Tensor,
                              encoder_outputs: Tensor) -> Tensor:

        a = self.attention(decoder_hidden, encoder_outputs)

        a = a.unsqueeze(1)

        encoder_outputs = encoder_outputs.permute(1, 0, 2)

        weighted_encoder_rep = torch.bmm(a, encoder_outputs)

        weighted_encoder_rep = weighted_encoder_rep.permute(1, 0, 2)

        return weighted_encoder_rep


    def forward(self,
                input: Tensor,
                decoder_hidden: Tensor,
                encoder_outputs: Tensor) -> Tuple[Tensor]:

        input = input.unsqueeze(0)

        embedded = self.dropout(self.embedding(input))

        weighted_encoder_rep = self._weighted_encoder_rep(decoder_hidden,
                                                          encoder_outputs)

        rnn_input = torch.cat((embedded, weighted_encoder_rep), dim = 2)

        output, decoder_hidden = self.rnn(rnn_input, decoder_hidden.unsqueeze(0))

        embedded = embedded.squeeze(0)
        output = output.squeeze(0)
        weighted_encoder_rep = weighted_encoder_rep.squeeze(0)

        output = self.out(torch.cat((output,
                                     weighted_encoder_rep,
                                     embedded), dim = 1))

        return output, decoder_hidden.squeeze(0)


class Seq2Seq(nn.Module):
    def __init__(self,
                 encoder: nn.Module,
                 decoder: nn.Module,
                 device: torch.device):
        super().__init__()

        self.encoder = encoder
        self.decoder = decoder
        self.device = device

    def forward(self,
                src: Tensor,
                trg: Tensor,
                teacher_forcing_ratio: float = 0.5) -> Tensor:

        batch_size = src.shape[1]
        max_len = trg.shape[0]
        trg_vocab_size = self.decoder.output_dim

        outputs = torch.zeros(max_len, batch_size, trg_vocab_size).to(self.device)

        encoder_outputs, hidden = self.encoder(src)

        # first input to the decoder is the <sos> token
        output = trg[0,:]

        for t in range(1, max_len):
            output, hidden = self.decoder(output, hidden, encoder_outputs)
            outputs[t] = output
            teacher_force = random.random() < teacher_forcing_ratio
            top1 = output.max(1)[1]
            output = (trg[t] if teacher_force else top1)

        return outputs


INPUT_DIM = len(SRC.vocab)
OUTPUT_DIM = len(TRG.vocab)
# ENC_EMB_DIM = 256
# DEC_EMB_DIM = 256
# ENC_HID_DIM = 512
# DEC_HID_DIM = 512
# ATTN_DIM = 64
# ENC_DROPOUT = 0.5
# DEC_DROPOUT = 0.5

ENC_EMB_DIM = 32
DEC_EMB_DIM = 32
ENC_HID_DIM = 64
DEC_HID_DIM = 64
ATTN_DIM = 8
ENC_DROPOUT = 0.5
DEC_DROPOUT = 0.5

enc = Encoder(INPUT_DIM, ENC_EMB_DIM, ENC_HID_DIM, DEC_HID_DIM, ENC_DROPOUT)

attn = Attention(ENC_HID_DIM, DEC_HID_DIM, ATTN_DIM)

dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, ENC_HID_DIM, DEC_HID_DIM, DEC_DROPOUT, attn)

model = Seq2Seq(enc, dec, device).to(device)


def init_weights(m: nn.Module):
    for name, param in m.named_parameters():
        if 'weight' in name:
            nn.init.normal_(param.data, mean=0, std=0.01)
        else:
            nn.init.constant_(param.data, 0)


model.apply(init_weights)

optimizer = optim.Adam(model.parameters())


def count_parameters(model: nn.Module):
    return sum(p.numel() for p in model.parameters() if p.requires_grad)


print(f'The model has {count_parameters(model):,} trainable parameters')

モデルはattention付きのseq2seqモデルである。

Encoderの処理
  1. 入力をembeddingする。
  2. RNN(双方向GRU)に通す。
  3. 双方向の隠れ状態をconcatして全結合層に渡してsoftmaxを通しcontext vectorとする。
  4. GRU出力(各単語ごとの出力)とcontext vectorを出力とする。
Decoderの処理
  1. 文の最初はEnocderで求めたcontext vectorを隠れ状態とし、開始文字を入力とする。
  2. 入力をembeddingする。
  3. 1つ前の隠れ状態とEncoder出力からattentionにより入力sequenceの各要素に対する重み和を計算する
  4. embeddingと重み和をconcatしてRNNに入力する。
  5. 2,3 ,4 の出力をconcatして次の文に対する入力とする。 4で得た隠れ状態を次の隠れ状態とする。
  6. 2 ~ 5までを文章の最大長さ(学習時はtargetの文章長)まで繰り返す。

結果

Perplexityに対する学習曲線は以下のようになっている。 f:id:nakamrnk:20200404113515p:plain
10エポック程度ではまだvalidationデータに対しても減少傾向を示しているため もう少し学習してもいいかもしれない。

予測例

言語
ドイツ語 eine junge frau und eine ältere frau in traditionellen saris spinnen , während drei weitere personen in moderner kleidung nur von der taille abwärts auf dem bild zu sehen sind .
正解英語 a young woman and older woman wear traditional saris as they spin , three people are pictured at only the , and wear modern clothes .
予測英語 a young woman woman in a woman , a woman , a table , a street , a street , a street , a street . . .

予測英語は文頭の数単語は正解しているが文の後半部分は同じような単語を連呼している。
Seq2Seqの構成上 文の最初はencoderの隠れ状態をそのまま利用できるのである程度正確な予測ができるが、 自身の出力を入力とするうちに徐々に誤差が蓄積してしまっていると思われる。

まとめと今後

今回はSLNIとMulti30Kの2つのデータセットについて実際にモデルを構築してみた。
SLNIは問題としては難しそうに感じたが簡単なモデルでもある程度学習できていて驚いた。
Multi30KのほうはAttentionつきのSeq2Seqに触れることができてよかったが性能がいまいちであったため実際の問題に適用する場合はパラメータ調整をしっかり行ったりやTransformer系の手法を試してみたい。
今後は文章要約をやってみたいと思った。

参考文献