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で以下のような処理を行っている。
- 前提文、仮説文を単語単位でembeddingする(embedding matrixは共有している)。
- 1.でembeddingした前提文、仮説文それぞれを LSTMを用いたEncodeに通す(Encoderも共有している)。
- EncodeしたベクトルをconcatしてMLP(4層)で分類を行う。
結果
学習曲線
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))
前処理では以下の処理を行っている。
- spacyでtokenize
- 文章の開始文字として
を設定 - 文章の終了文字として
を設定 - 大文字を小文字に変換
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の処理
- 入力をembeddingする。
- RNN(双方向GRU)に通す。
- 双方向の隠れ状態をconcatして全結合層に渡してsoftmaxを通しcontext vectorとする。
- GRU出力(各単語ごとの出力)とcontext vectorを出力とする。
Decoderの処理
- 文の最初はEnocderで求めたcontext vectorを隠れ状態とし、開始文字を入力とする。
- 入力をembeddingする。
- 1つ前の隠れ状態とEncoder出力からattentionにより入力sequenceの各要素に対する重み和を計算する
- embeddingと重み和をconcatしてRNNに入力する。
- 2,3 ,4 の出力をconcatして次の文に対する入力とする。 4で得た隠れ状態を次の隠れ状態とする。
- 2 ~ 5までを文章の最大長さ(学習時はtargetの文章長)まで繰り返す。
結果
Perplexityに対する学習曲線は以下のようになっている。
10エポック程度ではまだvalidationデータに対しても減少傾向を示しているため
もう少し学習してもいいかもしれない。
予測例
言語 | 文 |
---|---|
ドイツ語 | |
正解英語 | |
予測英語 | |
予測英語は文頭の数単語は正解しているが文の後半部分は同じような単語を連呼している。
Seq2Seqの構成上 文の最初はencoderの隠れ状態をそのまま利用できるのである程度正確な予測ができるが、
自身の出力を入力とするうちに徐々に誤差が蓄積してしまっていると思われる。
まとめと今後
今回はSLNIとMulti30Kの2つのデータセットについて実際にモデルを構築してみた。
SLNIは問題としては難しそうに感じたが簡単なモデルでもある程度学習できていて驚いた。
Multi30KのほうはAttentionつきのSeq2Seqに触れることができてよかったが性能がいまいちであったため実際の問題に適用する場合はパラメータ調整をしっかり行ったりやTransformer系の手法を試してみたい。
今後は文章要約をやってみたいと思った。