[文章生成]PyTorchのRNNクラスを使って文章生成を行う準備をしよう作って試そう! ディープラーニング工作室(1/2 ページ)

青空文庫から取得した小説データのインデックスへの変換、インデックスのベクトル化、RNNへの入力など、文章生成の準備と全体の流れを確認します。

» 2021年03月19日 05時00分 公開
[かわさきしんじDeep Insider編集部]

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「作って試そう! ディープラーニング工作室」のインデックス

連載目次

今回の目的

 前回はマルコフ連鎖を用いて、青空文庫で公開されている梶井基次郎の著作データから文章を生成しました。今回から数回に分けてディープラーニングの手法を用いて、文章の生成に挑戦してみましょう。

 ここで一つ考えたいのは、文章というものの構造です。例えば、梶井基次郎の『檸檬』には「檸檬などごくありふれている。」という1文があります。これを分かち書きにすると「檸檬 など ごく ありふれ て いる 。」となりますが、これは「檸檬」→「など」→「ごく」→「ありふれ」→「て」→「いる」→「。」と形態素が連続して登場する(時系列)データだと考えられます。こうしたデータを扱うのに適したニューラルネットワークとしてRNNがあります。本連載でも「RNNに触れてみよう:サイン波の推測」などで少し触れました。今回はこのRNNを用いて文章を生成するための準備を、次回は実際に文章を生成する予定です。

 ここで問題なのは、分かち書きにより形態素へと分解された日本語の文章をそのままニューラルネットワークに投入するわけにはいきません。ニューラルネットワークが扱うのは文字列ではなく、一定の形式を持った数値列(テンソル)ですから。というわけで、分かち書きにしたデータを数値に変換する(そして、数値から元の文章に復元する)仕組みも必要になります。また、これを実際にニューラルネットワークで処理するには個々のインデックスをベクトル化する必要もあります。

 というわけで、今回と次回では次のようなことを行います。

  1. 分かち書きされた文章を基に、形態素にインデックスを振り、相互に変換可能な辞書を作成
  2. 作成した辞書を使って、分かち書きされた文章をインデックス列へ変換
  3. インデックス列をPyTorchで扱えるDataSetクラスとする
  4. インデックス列に変換された文章をベクトル化する
  5. ベクトル化した文章をRNNに入力する
  6. RNNから出力を全結合層に入力する
  7. 全結合層の出力は最初に作成した辞書の要素の中で特定の形態素の次に登場する形態素を推測するものとする
  8. 上記を行うクラスを定義して、学習を行うコード、実際に文章生成を行うコードを記述

 最後のステップでは、全結合層の出力の数は辞書と同じサイズとします。これは、「檸檬」という形態素をこのニューラルネットワークに入力したときに、辞書の中で「など」や「が」あるいは「を」や「の」などの形態素に割り振られたインデックスに対応する出力の値が高めに出るように学習させることで、文章を生成するときに適切な形態素を選択できるようにしようという考えです。

 今回は上記のステップ3までを行うコードを見た後、ステップ4以降でどんな処理を行うのか、そのひな型となるコードを実行してみましょう。

辞書の作成

 まずは分かち書きテキストから形態素とインデックスとの間で相互に変換を行うための辞書を2つ作成します。1つは形態素からインデックスを求める辞書で、もう1つはインデックスから形態素を求める辞書です。なお、分かち書きされたテキストは次のようなものです。

えたい の 知れ ない 不吉 な 塊 が 私 の 心 を 始終 圧え つけ て い た 。
焦躁 と 言おう か 、 嫌悪 と 言おう か ― ― 酒 を 飲ん だ あと に 宿酔 が ある
よう に 、 酒 を 毎日 飲ん で いる と 宿酔 に 相当 し た 時期 が やっ て 来る 。
それ が 来 た の だ 。
これ は ちょっと いけ なかっ た 。
結果 した 肺尖 カタル や 神経衰弱 が いけ ない の で は ない 。
また 背 を 焼く よう な 借金 など が いけ ない の で は ない 。


分かち書きされたテキスト(一部を抜粋)
底本:「檸檬・ある心の風景 他二十編」旺文社文庫、旺文社
   1972(昭和47)年12月10日初版発行
   1974(昭和49)年第4刷発行
初出:「青空 創刊号」青空社
   1925(大正14)年1月
表題は底本では、「檸檬れもん」となっています。
編集部による傍注は省略しました。
入力:j.utiyama
校正:野口英司
1998年8月31日公開
2016年7月5日修正
青空文庫作成ファイル:
このファイルは、インターネットの図書館、青空文庫(http://www.aozora.gr.jp/)で作られました。入力、校正、制作にあたったのは、ボランティアの皆さんです。

 この内容を含んだテキストファイル(wakati.txt)を作成するコードは今回のノートブックの末尾に記載してあります(作成されたファイル内での行の順番は本稿で扱っているものとは異なるかもしれません)。これを作成したら、ノートブックの左端にある[ファイル]アイコンをクリックして、[wakati.txt]の右側にあるメニューをクリックして[ダウンロード]を選択し、ローカルマシンにファイルをダウンロードしておいてください。

wakati.txtファイルのダウンロード wakati.txtファイルのダウンロード

 その後は必要に応じて、[ファイル]タブの上部にある[セッション ストレージにアップロード]ボタンをクリックし、ファイルをアップロードするとよいでしょう。

wakati.txtファイルのアップロード wakati.txtファイルのアップロード

 では、辞書を作成するコードを以下に示します。

with open('wakati.txt') as f:
    corpus = f.read()

corpus = corpus.split('\n')

def make_dic(corpus):
    word2id = {}
    id2word = {}
    for line in corpus:
        if line == ''# 空行はスキップ
            continue
        if '(' in line or '―' in line:  # かっこと「―」を含む文はスキップ
            continue
        for word in line.split(' '):
            if word not in word2id:
                id = len(word2id) + 1  # id=0はパディング用にとっておく
                word2id[word] = id
                id2word[id] = word
    return word2id, id2word

辞書を作成するmake_dic関数

 ここでwakati.txtファイルから読み出した内容は変数corpusに格納されて(次に改行コードで分割されたリストになって)いますが、ここでいうコーパス(corpus)とは、辞書にある語句を使用して作られた文例集のことだと考えられます(ここでは文例集から辞書を作成していますが)。

 make_dic関数の中では、各行の内容を半角空白文字で形態素に分解して(その後、全角開きかっこと全角のダッシュ「―」を含む行は処理をスキップし)、形態素が辞書にまだ登録されていなければ、その形態素にインデックスを振り、「形態素: インデックス」と「インデックス: 形態素」の組を要素とする辞書に追加しているだけです。このとき、id=0は後で説明するパディング用に予約してインデックスは1から振ることにしました。

 この関数を呼び出して、辞書を作成するコードは次の通りです。

w2i, i2w = make_dic(corpus)

print(w2i)
print(i2w)

読み込んだテキストファイルの内容を渡して辞書を作成

 実行結果は次のようになりました。

実行結果 実行結果

 これで形態素とインデックスが相互に変換できるようになりました。

分かち書きされたテキストのインデックス列への変換

 次に、wakati.txtファイルの各行に格納されている形態素を上で作成した辞書を使ってインデックスに変換します。実際にこれを行うコードおよびその逆を行うコードを以下に示します。

def word2id(corpus, word_to_id, max_length):
    result = []
    for line in corpus:
        if line == ''# 空行はスキップ
            continue
        if '(' in line or '―' in line:  # かっこと「―」を含む文はスキップ
            continue
        tmp = [word_to_id[word] for word in line.split(' ')]
        if len(tmp) > max_length:  # 形態素の数がmax_lengthより大きければ省略
            continue
        tmp += [0] * (max_length - len(tmp))
        result.append(tmp)
    return result

def id2word(id_data, id_to_word):
    result = ''
    for line in id_data:
        result += ''.join([id_to_word[id] for id in line if id != 0]) + '\n'
    return result

分かち書きされたテキストをインデックスで構成されるリストに変換するコード

 word2id関数がテキストをインデックスに、id2word関数がその逆を行う関数です。word2id関数はコーパスとなる分かち書きされたテキスト(corpus)、先ほど作成した形態素からインデックスへ変換するための辞書(word_to_id)、それから1文に含まれる形態素の数の上限値(max_length)をパラメーターに持ちます。corpusの各行を半角空白文字で分割して形態素(に対応するインデックス)のリストに変換した後にその要素数を数えてmax_lengthと比較して、この値より長ければ処理をスキップするようにしています。その後、要素がmax_lengthよりも少なければ、最後に辞書では形態素に割り当てなかったインデックスである「0」を足りない数だけパディングとして追加しています。これはニューラルネットワークに常に同じ数のデータを入力するための処理です。こうしてできたインデックスのリストを要素とするリストを作成するのがword2id関数の仕事になります。

 これに対して、id2word関数はシンプルです。受け取ったインデックス列を先ほど作成したインデックスから形態素に変換する辞書を使用して、元に戻すだけです(このとき、インデックス=0なら変換を行わないようにしています)。

 実際に変換をしてみましょう。

max_length = 20
id_data = word2id(corpus, w2i, max_length)
print(len(id_data))
print(id_data[0:2])
print(id2word(id_data[0:2], i2w))
print(corpus[0:1])

分かち書きされたテキストからインデックス列を要素とするリストを作成

 実行結果は次の通りです。

実行結果 実行結果

 ここでは形態素の上限は20個としました。この上限値と、コーパスと辞書を指定してword2id関数を呼び出すだけです。作成されたインデックス列は3752個ということも分かりました(読者が同じコードを実行しても異なる結果となる可能性はあります)。最後の3行では、できあがったインデックス列のリストから先頭の2つを表示して、それをid2word関数で復元したもの、それらに対応する元テキストを表示しています。どうやらうまくインデックス列に変換したり、それをテキストに復元できたりしているようです。

インデックス列をPyTorchで扱えるDataSetクラスとする

 ここまでは純粋にPythonでのファイル/文字列/リスト/辞書操作の話でしたが、ここからは久しぶりにPyTorchの話になります。

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt
import numpy as np

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

 ここでは、上で作成したインデックス列(のリスト)をPyTorchのデータローダーを使ってバッチサイズごとに読み込めるようにするために、このインデックス列を内部に持つデータセットを定義しましょう。

 といってもコードは以下のようにシンプルです。

class KajiiDataset(Dataset):
    def __init__(self, id_data):
        super().__init__()
        self.data_length = len(id_data)
        # 訓練データ。例:['僕', 'は', 'カレー', 'が', '好き']
        self.x = [row[0:-1] for row in id_data]
        # 正解ラベル。例:['は', 'カレー', 'が', '好き', '。']
        self.y = [row[1:] for row in id_data]
    
    def __len__(self):
        return self.data_length
    
    def __getitem__(self, idx):
        return torch.tensor(self.x[idx]), torch.tensor(self.y[idx])

KajiiDatasetクラス

 ここではtorch.utils.data.Datasetクラスを継承する形でKajiiDatasetを定義しています。

 __init__メソッドにはselfに加えてもう1つ、id_dataというパラメーターがあります。このパラメーターにはもちろん、word2id関数で作成したインデックス列のリストを受け取ります。id_dataに含まれているインデックス列の先頭から最後から2番目までの要素が訓練データで、インデックス列の2番目から最後までの要素を正解ラベルです(上の例にあるように「'僕 は カレー が 好き 。'」というのが元の入力(を形態素にしたもの)だとすると、「僕」に対する正解ラベルが「は」で、「は」に対する正解ラベルが「カレー」のようになっているということです。実際にはid_dataには形態素をインデックスに変換したものが含まれていることと、多くの場合は最後にパディングとして0が埋め込まれていることには注意してください。

 なお、PyTorchのDataSetクラスのドキュメントを見ると、Datasetクラスの派生クラスでは__getitem__メソッドを上書き(オーバーライド)して要素の取得が可能になるようにしなければならず、またオプションで__len__メソッドを実装してもよいと書いてあります。そこで、上のコードではそれら2つのメソッドを定義しています。

 __len__メソッドは単に受け取ったインデックス列の要素数を返すだけです。__getitem__メソッドは指定されたインデックスにある訓練データと正解ラベルをPyTorchのテンソルに変換して返すだけで、こちらもシンプルになっています。

 では、このKajiiDatasetクラスを使ってデータセットのインスタンスを作成してみましょう。

dataset = KajiiDataset(id_data)
dataset[0]

KajiiDatasetクラスのインスタンス生成

 実行結果は次のようになります。

実行結果 実行結果

 実行結果を見ると、訓練データである1つ目のテンソルと、正解ラベルである2つ目のテンソルが1つズレていることが分かります(各テンソルのデータ数はmax_lengthで指定した値よりも1つ少なくなります)。

 これでニューラルネットワークに入力するデータの準備が完了したといえるでしょう。ここでストレートにPyTorchのニューラルネットワークモジュールの定義に進んでもよいのですが、実際にはニューラルネットワークモデルに上に示したようなデータを入力したときに、最終的にどのような出力が得られるのかを段階を踏んで見てみることにしましょう。

       1|2 次のページへ

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。