PytorchのTransformersのT5を使って要約モデルを作る

スポンサーリンク

 
インターネットの世界にニュースが溢れる昨今、満足度が高いものを的確に読みたいという方も多いかと思います。そのためには、見るニュースをどれにするか判断することが必要になります。そこで、ニュース全体の主旨を短い文章で表す要約の価値が高まっています。


自然言語処理における要約は、大きく2つに分けられます。それは、抽出型と抽象型です。抽出型は、文章の中から重要な文を抜き出すことで要約を作ります。要約として選ばれた文は元の文章にあるものなので、方向性が大きく異ることや誤字脱字がうまれる可能性は低いです。しかし、要約として選ばれた文のそれぞれは関係があるわけではないので、流暢な要約にならないことも多いです。それに対して、抽象型は人間が作るように要約としての文章の流暢さを考慮しながら作ります。本来人間がほしい要約はこちらになりますが、抽出型に比べると難易度が上がり、全く意味がわからない文章になる可能性もあります。


これまでは、抽象型の要約文章は難しいと言われてきましたが、Deep Learning の進化により可能になってきました。今回は Transformers の T5 を使って日本語要約モデルを構築します。本日のコードはこちらに置いてあります。該当する Jupyer Notebook は日本語要約生成モデルの構築です。



準備


今回は GPU を使いたいので、Google Colaboratory で行います。Google Colaboratory を自身の Google Drive アカウントに紐付け、作業フォルダーの作成をします。そして、日本語の要約モデルを作るために必要なモジュールをインポートし、日本語の要約データを取得します。


Google Colaboratory での準備


GPU を使ってモデルを学習させるので、Google Colaboratory を使います。そのために、自身の Google Drive に接続(マウント)し、今回作業するディレクトリを作成、そのディレクトリに移動します。

from google.colab import drive
drive.mount('/content/drive')

import os
path = '/content/drive/MyDrive/google_colaboratory/document_summarization/'
if not os.path.exists(path):
    os.makedirs(path)

%cd /content/drive/MyDrive/google_colaboratory/document_summarization/



必要なモジュールのインポート


今後必要になるパラメータを手軽に管理するために「settings.py」に記載しておきます。

import torch

MODEL_NAME = "sonoisa/t5-base-japanese"
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

max_length_src = 400
max_length_target = 200

batch_size_train = 8
batch_size_valid = 8

epochs = 1000
patience = 20


今回使用するモジュールとして重要なのは、Pytorch と Transformers です。この2つで Deep Learning を構築しています。

from pathlib import Path
import re
import math
import time
import copy
from tqdm import tqdm
import pandas as pd
import tarfile
import neologdn
from sklearn.model_selection import train_test_split
import torch
import torch.nn as nn
from torch import optim
from torch.utils.data import DataLoader
from transformers import T5ForConditionalGeneration, T5Tokenizer
import settings



データの取得


日本語の要約モデルを作ることの一番の難しさは、日本語の要約データの取得です。これが簡単には手に入らないため、前回の記事で日本語の要約データの取得について書きました。ここでは、日本語要約データを取得済みとして進めていくので、まだの方は下記の記事をご覧ください。


www.dskomei.com


今回使用する日本語要約データは、livedoor ニュースの3行要約データです。このデータの中身を見てみます。上の記事では「data」ディレクトリにデータを格納しています。

body_data = pd.read_csv(data_dir_path.joinpath('body_data.csv'))
summary_data = pd.read_csv(data_dir_path.joinpath('summary_data.csv'))

pd.merge(
    body_data.query('text.notnull()', engine='python').rename(columns={'text': 'body'}),
    summary_data.rename(columns={'text': 'summary'}),
    on='article_id', how='inner'
).sort_values('article_id').head(10)

f:id:dskomei:20211111092428p:plain:w600


今回のデータは、一つの記事に対して3行の要約があります。このデータを使って、記事本文をもとに3行予約を予測するモデルを作ります。


学習データの作成


ここでは、テキストを整形して記事IDで結合するデータの前処理、前処理したデータのベクトル化に関して記載します。


データの前処理


要約データは一つの記事に対して3行あり、一文ごとに一つのレコードになっているので、同じ記事の要約を「。」で結合します。そして、記事本文データと要約データを結合し、テキストの不要な文字の削除や正規化を行います。

def join_text(x, add_char='。'):
    return add_char.join(x)

def preprocess_text(text):
    text = re.sub(r'[\r\t\n\u3000]', '', text)
    text = neologdn.normalize(text)
    text = text.lower()
    text = text.strip()
    return text

summary_data = summary_data.query('text.notnull()', engine='python').groupby(
    'article_id'
).agg({'text': join_text})

body_data = body_data.query('text.notnull()', engine='python')

data = pd.merge(
    body_data.rename(columns={'text': 'body_text'}),
    summary_data.rename(columns={'text': 'summary_text'}),
    on='article_id', how='inner'
).assign(
    body_text=lambda x: x.body_text.map(lambda y: preprocess_text(y)),
    summary_text=lambda x: x.summary_text.map(lambda y: preprocess_text(y))
)



学習用データへの変換


要約本文結合データを学習用データとテスト用データにわけ、それぞれをトークナイザーを使ってテキストの文字を数値化し、バッチサイズずつデータを束ねます。

def convert_batch_data(train_data, valid_data, tokenizer):

    def generate_batch(data):

        batch_src, batch_tgt = [], []
        for src, tgt in data:
            batch_src.append(src)
            batch_tgt.append(tgt)

        batch_src = tokenizer(
            batch_src, max_length=settings.max_length_src, truncation=True, padding="max_length", return_tensors="pt"
        )
        batch_tgt = tokenizer(
            batch_tgt, max_length=settings.max_length_target, truncation=True, padding="max_length", return_tensors="pt"
        )

        return batch_src, batch_tgt

    train_iter = DataLoader(train_data, batch_size=settings.batch_size_train, shuffle=True, collate_fn=generate_batch)
    valid_iter = DataLoader(valid_data, batch_size=settings.batch_size_valid, shuffle=True, collate_fn=generate_batch)

    return train_iter, valid_iter

tokenizer = T5Tokenizer.from_pretrained(settings.MODEL_NAME, is_fast=True)

X_train, X_test, y_train, y_test = train_test_split(
    data['body_text'], data['summary_text'], test_size=0.2, random_state=42, shuffle=True
)

train_data = [(src, tgt) for src, tgt in zip(X_train, y_train)]
valid_data = [(src, tgt) for src, tgt in zip(X_test, y_test)]

train_iter, valid_iter = convert_batch_data(train_data, valid_data, tokenizer)



日本語要約モデルの構築


ここまでで準備は全て終わりです。あとは、要約モデルを設計し、作った学習データで学習させるのみです。


モデル定義クラス


TextToText の学習済みモデルを取得するために、「transformers」モジュールから T5 モデルを取得します。そのモデルを Pytorch の nn.Module を継承させたクラス内で作り、「forward」関数でテキストを生成します。

class T5FineTuner(nn.Module):
    
    def __init__(self):
        super().__init__()

        self.model = T5ForConditionalGeneration.from_pretrained(settings.MODEL_NAME)

    def forward(
        self, input_ids, attention_mask=None, decoder_input_ids=None,
        decoder_attention_mask=None, labels=None
    ):
        return self.model(
            input_ids,
            attention_mask=attention_mask,
            decoder_input_ids=decoder_input_ids,
            decoder_attention_mask=decoder_attention_mask,
            labels=labels
        )



モデル学習処理関数


Transformers モデルを使うと、入力データを与えただけで、損失値の計算までしてくれます。特段変わったことはしていないので、コードの理解は難しく無いと思います。

def train(model, data, optimizer, PAD_IDX):
    
    model.train()
    
    loop = 1
    losses = 0
    pbar = tqdm(data)
    for src, tgt in pbar:
                
        optimizer.zero_grad()
        
        labels = tgt['input_ids'].to(settings.device)
        labels[labels[:, :] == PAD_IDX] = -100

        outputs = model(
            input_ids=src['input_ids'].to(settings.device),
            attention_mask=src['attention_mask'].to(settings.device),
            decoder_attention_mask=tgt['attention_mask'].to(settings.device),
            labels=labels
        )
        loss = outputs['loss']

        loss.backward()
        optimizer.step()
        losses += loss.item()
        
        pbar.set_postfix(loss=losses / loop)
        loop += 1
        
    return losses / len(data)

def evaluate(model, data, PAD_IDX):
    
    model.eval()
    losses = 0
    with torch.no_grad():
        for src, tgt in data:

            labels = tgt['input_ids'].to(settings.device)
            labels[labels[:, :] == PAD_IDX] = -100

            outputs = model(
                input_ids=src['input_ids'].to(settings.device),
                attention_mask=src['attention_mask'].to(settings.device),
                decoder_attention_mask=tgt['attention_mask'].to(settings.device),
                labels=labels
            )
            loss = outputs['loss']
            losses += loss.item()
        
    return losses / len(data)



モデルの学習


モデルの設計までが終わったので、後はモデルを学習させるだけです。先程作成した学習処理関数を呼んで、指定したループ回数分学習を行います。ただ、一定回数損失値が改善されなければ、学習を終える処理も追加しています。

model = T5FineTuner()
model = model.to(settings.device)

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

PAD_IDX = tokenizer.pad_token_id
best_loss = float('Inf')
best_model = None
counter = 1

for loop in range(1, settings.epochs + 1):

    start_time = time.time()

    loss_train = train(model=model, data=train_iter, optimizer=optimizer, PAD_IDX=PAD_IDX)

    elapsed_time = time.time() - start_time

    loss_valid = evaluate(model=model, data=valid_iter, PAD_IDX=PAD_IDX)

    print('[{}/{}] train loss: {:.4f}, valid loss: {:.4f} [{}{:.0f}s] counter: {} {}'.format(
        loop, settings.epochs, loss_train, loss_valid,
        str(int(math.floor(elapsed_time / 60))) + 'm' if math.floor(elapsed_time / 60) > 0 else '',
        elapsed_time % 60,
        counter,
        '**' if best_loss > loss_valid else ''
    ))

    if best_loss > loss_valid:
        best_loss = loss_valid
        best_model = copy.deepcopy(model)
        counter = 1
    else:
        if counter > settings.patience:
            break

        counter += 1

f:id:dskomei:20211112091546p:plain:w600


モデルの保存


学習したモデルを指定したディレクトリに保存します。関数を呼び出すだけなので簡単にできます。

model_dir_path = Path('model')
if not model_dir_path.exists():
    model_dir_path.mkdir(parents=True)

tokenizer.save_pretrained(model_dir_path)
best_model.model.save_pretrained(model_dir_path)



学習済みモデルを使って文章から要約を生成


学習済みモデルができたので、そのモデルを使って文章を生成します。そのために、記事本文から要約を生成する関数を作ります。そして、学習済みモデルのインスタンスを作っておきます。

def generate_text_from_model(text, trained_model, tokenizer, num_return_sequences=1):

    trained_model.eval()
    
    text = preprocess_text(text)
    batch = tokenizer(
        [text], max_length=settings.max_length_src, truncation=True, padding="longest", return_tensors="pt"
    )

    # 生成処理を行う
    outputs = trained_model.generate(
        input_ids=batch['input_ids'].to(settings.device),
        attention_mask=batch['attention_mask'].to(settings.device),
        max_length=settings.max_length_target,
        repetition_penalty=8.0,   # 同じ文の繰り返し(モード崩壊)へのペナルティ
        # temperature=1.0,  # 生成にランダム性を入れる温度パラメータ
        # num_beams=10,  # ビームサーチの探索幅
        # diversity_penalty=1.0,  # 生成結果の多様性を生み出すためのペナルティパラメータ
        # num_beam_groups=10,  # ビームサーチのグループ
        num_return_sequences=num_return_sequences,  # 生成する文の数
    )

    generated_texts = [
        tokenizer.decode(ids, skip_special_tokens=True, clean_up_tokenization_spaces=False) for ids in outputs
    ]

    return generated_texts

tokenizer = T5Tokenizer.from_pretrained(model_dir_path)
trained_model = T5ForConditionalGeneration.from_pretrained(model_dir_path)
trained_model = trained_model.to(settings.device)

上記の関数を使って結果を確認します。

index = 1
body = valid_data[index][0]
summaries = valid_data[index][1]
generated_texts = generate_text_from_model(
    text=body, trained_model=trained_model, tokenizer=tokenizer, num_return_sequences=1
)
print('□ 生成本文')
print('\n'.join(generated_texts[0].split('。')))
print()
print('□ 教師データ要約')
print('\n'.join(summaries.split('。')))
print()
print('□ 本文')
print(body)

f:id:dskomei:20211112092724p:plain:w600


結果を見てもらうと、生成された文章としての意味はおかしくないように見えます。この生成文章だけを見ると、人間が作ったものと思う人が多いでしょう。しかしながら、要約としては正しくない文章です。記事本文としては、「仕様変更されたが、もとに戻された」「問題点があるよ」ということも主旨であり、教師データの要約には含まれています。日本語としての意味はおかしくないですが、要約の質に関しては改善の余地があります。


終わりに


今回は、日本語の要約モデルを生成することに関して記載しました。生成文章に関わらず、日本語のおかしな文章にならなかったことは、注目すべき点だと感じました。ただ、要約の質の観点ではまだまだ改善の余地があります。


自然言語処理のディープラーニングを勉強する上で非常にためになったのが以下の本です。是非ご覧ください。