NLP を学ぶ - 5

はじめに

文章要約は与えられた文章を要約するタスクである。
文章要約には

  1. 抽出型(Extractive Summarization)
  2. 抽象型(Abstractive Summarization)

の2種類が存在し1、抽出型は与えられた文章から重要と思われる文章や単語を抽出するタスクであり、抽象型は新しい文章として要約文を生成するタスクである。

今回は以下のデータセット
https://www.kaggle.com/sunnysai12345/news-summary
からSeq2Seqにより抽象型の文章要約モデルを学習してみた。
検証環境はGoogle Colaboratory。

前処理・データ解析

Kaggleのデータセットをダウンロード

参考 : https://qiita.com/katsu1110/items/a8d508a1b6f07bd3a243

コード - Kaggleのデータセットダウンロード + 環境設定

## Kaggle APIを使ってデータをダウンロード  + ライブラリ設定##

# 自分のアカウントで作成したkeyをアップロード
from google.colab import files
files.upload()


# keyに権限を付与
!mkdir -p ~/.kaggle
!mv kaggle.json ~/.kaggle/
from googleapiclient.discovery import build
import io, os
from googleapiclient.http import MediaIoBaseDownload
from google.colab import auth

auth.authenticate_user()
drive_service = build('drive', 'v3')
results = drive_service.files().list(
        q="name = 'kaggle.json'", fields="files(id)").execute()
kaggle_api_key = results.get('files', [])

filename = "/root/.kaggle/kaggle.json"
os.chmod(filename, 600)


# kaggle APIによるデータダウンロード 
!pip install kaggle
!kaggle datasets download sunnysai12345/news-summary
!unzip news-summary.zip


# torchtextを適切なバージョンに
!pip install torchtext==0.5

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

データセットの構築

今回のデータセットcsvファイル形式なのでtorchtext.data.Datasetを継承し、
専用のDatasetを実装する。

import pandas as pd
import torchtext
from torchtext.data import Field, BucketIterator
from tqdm.notebook import tqdm

# 専用のデータセットを実装
class NewsSummaryDataset(torchtext.data.Dataset):
  # BucketIterator構築時に利用
  @staticmethod
  def sort_key(ex):
    return torchtext.data.interleave_keys(len(ex.src), len(ex.trg))

  # DataFrame の src_col, trg_colの列をそれぞれsrc, targetとする
  def __init__(self, df, fields, 
               src_col="text", trg_col="headlines",
               **kwargs):   
    if not isinstance(fields[0], (tuple, list)):
      fields = [('src', fields[0]), ('trg', fields[1])]
    examples = []
    for i, row in tqdm(df.iterrows(), total=df.shape[0]):
      src_line = row[src_col]
      trg_line = row[trg_col]
      examples.append(torchtext.data.Example.fromlist([src_line, trg_line], fields))
    print("number of examples : ", len(examples))
    super().__init__(examples, fields, **kwargs)

  # csvファイルを読み込み
  # 文字コードがutf-8でなかったので指定。
  @classmethod
  def splits(cls, fields, 
             in_file="news_summary_more.csv", encoding="iso-8859-1",
             train_ratio=0.8, **kwargs):
    print("loading file : ", in_file)
    news_df = pd.read_csv(in_file, encoding=encoding)
    train_df = news_df.sample(frac=train_ratio)
    valid_df = news_df.drop(train_df.index)
    print("train data ")
    train_data = cls(train_df, fields, **kwargs)    
    print("valid data ")
    valid_data = cls(valid_df, fields, **kwargs)
    return (train_data, valid_data)


# 前処理は共通
FIELD = Field(tokenize = "spacy",
            tokenizer_language="en",
            init_token = '<sos>',
            eos_token = '<eos>',
            lower = True)

# データセット作成
train_dataset, valid_dataset = NewsSummaryDataset.splits((FIELD, FIELD))
print("train : ", len(train_dataset), " valid : ", len(valid_dataset))

コード - 統計量計算

FIELD.build_vocab(train_dataset, min_freq = 2)
# 統計量計算
import pandas as pd
datasets = [train_dataset, valid_dataset]
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)))

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

統計量 学習 評価
文章数 78721 19680
元文章 単語数 5411180 1353060
要約文 単語数 853616 213989
語彙数 54673 54673

元文章は1文章70単語程度で要約文は10単語程度である。

コード - DataLoader作成

import torch

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

BATCH_SIZE = 128
train_iterator, valid_iterator = BucketIterator.splits(
    (train_dataset, valid_dataset),
    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(FIELD.vocab)
OUTPUT_DIM = len(FIELD.vocab)

ENC_EMB_DIM = 64
DEC_EMB_DIM = 64
ENC_HID_DIM = 128
DEC_HID_DIM = 128
ATTN_DIM = 16
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。

コード - 学習・評価

PAD_IDX = FIELD.vocab.stoi['<pad>']
criterion = nn.CrossEntropyLoss(ignore_index=PAD_IDX)

import math
import time


def train(model: nn.Module,
          iterator: BucketIterator,
          optimizer: optim.Optimizer,
          criterion: nn.Module,
          clip: float):

    model.train()
    epoch_loss = 0

    for _, batch in tqdm(enumerate(iterator), total=len(iterator)):

        src = batch.src
        trg = batch.trg

        optimizer.zero_grad()

        output = model(src, trg)

        output = output[1:].view(-1, output.shape[-1])
        trg = trg[1:].view(-1)

        loss = criterion(output, trg)

        loss.backward()

        torch.nn.utils.clip_grad_norm_(model.parameters(), clip)

        optimizer.step()

        epoch_loss += loss.item()

    return epoch_loss / len(iterator)


def evaluate(model: nn.Module,
             iterator: BucketIterator,
             criterion: nn.Module):

    model.eval()

    epoch_loss = 0

    with torch.no_grad():

        for _, batch in enumerate(iterator):

            src = batch.src
            trg = batch.trg

            output = model(src, trg, 0) #turn off teacher forcing

            output = output[1:].view(-1, output.shape[-1])
            trg = trg[1:].view(-1)

            loss = criterion(output, trg)

            epoch_loss += loss.item()

    return epoch_loss / len(iterator)


def epoch_time(start_time: int,
               end_time: int):
    elapsed_time = end_time - start_time
    elapsed_mins = int(elapsed_time / 60)
    elapsed_secs = int(elapsed_time - (elapsed_mins * 60))
    return elapsed_mins, elapsed_secs


N_EPOCHS = 15
CLIP = 1

best_valid_loss = float('inf')
logs = []
for epoch in range(N_EPOCHS):

    start_time = time.time()

    train_loss = train(model, train_iterator, optimizer, criterion, CLIP)
    valid_loss = evaluate(model, valid_iterator, criterion)

    end_time = time.time()

    epoch_mins, epoch_secs = epoch_time(start_time, end_time)

    print(f'Epoch: {epoch+1:02} | Time: {epoch_mins}m {epoch_secs}s')
    print(f'\tTrain Loss: {train_loss:.3f} | Train PPL: {math.exp(train_loss):7.3f}')
    print(f'\t Val. Loss: {valid_loss:.3f} |  Val. PPL: {math.exp(valid_loss):7.3f}')
    logs.append((epoch + 1, train_loss, valid_loss))

logs_df = pd.DataFrame(logs, columns=["epoch", "train", "valid"]).set_index("epoch")

学習設定
エポック数 15
バッチサイズ 128
optimzier Adam(学習率はdefault)
損失関数 Cross Entropy

結果

学習曲線

f:id:nakamrnk:20200406125443p:plain

15エポック程度でvalidationデータに対する性能(loss)はほぼ収束している。
trainデータについてはまだ減少傾向にある。

推論例

文章
元文章 the congress on sunday released its first list of 218 candidates for the karnataka assembly elections scheduled for may 12 . incumbent cm siddaramaiah will contest the election from the chamundeshwari seat , while his son dr will stand from varuna . the bjp had released its first list of 72 candidates for the assembly election on april 8 .
正解要約 congress releases candidate list for karnataka polls
予測要約 cong releases first list candidates for karnataka assembly polls karnataka

このサンプルの予測要約文は文法的に間違っている部分があるが、いくつか正解に近い単語を選択できている。 前回の機械翻訳の問題と比較すると出力文の長さが比較的短いためSeq2Seqでもある程度推論できているのだろうか。

まとめと今後

今回はテキスト要約のデータセットに触れてみた。
Deep Learning関連は前回の機械翻訳と同様だったが、
torchtextのDataset構築を1から行う経験はできた。
翻訳タスクよりは意味のありそうなモデルを学習することができた。
今回まででNLP関連の主要タスクにいくつか触れることができたので
今後はBERTなどの最新のNLP関連の手法を調査・検証していきたい。

参考文献