青空文庫から取得した小説データのインデックスへの変換、インデックスのベクトル化、RNNへの入力など、文章生成の準備と全体の流れを確認します。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
前回はマルコフ連鎖を用いて、青空文庫で公開されている梶井基次郎の著作データから文章を生成しました。今回から数回に分けてディープラーニングの手法を用いて、文章の生成に挑戦してみましょう。
ここで一つ考えたいのは、文章というものの構造です。例えば、梶井基次郎の『檸檬』には「檸檬などごくありふれている。」という1文があります。これを分かち書きにすると「檸檬 など ごく ありふれ て いる 。」となりますが、これは「檸檬」→「など」→「ごく」→「ありふれ」→「て」→「いる」→「。」と形態素が連続して登場する(時系列)データだと考えられます。こうしたデータを扱うのに適したニューラルネットワークとしてRNNがあります。本連載でも「RNNに触れてみよう:サイン波の推測」などで少し触れました。今回はこのRNNを用いて文章を生成するための準備を、次回は実際に文章を生成する予定です。
ここで問題なのは、分かち書きにより形態素へと分解された日本語の文章をそのままニューラルネットワークに投入するわけにはいきません。ニューラルネットワークが扱うのは文字列ではなく、一定の形式を持った数値列(テンソル)ですから。というわけで、分かち書きにしたデータを数値に変換する(そして、数値から元の文章に復元する)仕組みも必要になります。また、これを実際にニューラルネットワークで処理するには個々のインデックスをベクトル化する必要もあります。
というわけで、今回と次回では次のようなことを行います。
最後のステップでは、全結合層の出力の数は辞書と同じサイズとします。これは、「檸檬」という形態素をこのニューラルネットワークに入力したときに、辞書の中で「など」や「が」あるいは「を」や「の」などの形態素に割り振られたインデックスに対応する出力の値が高めに出るように学習させることで、文章を生成するときに適切な形態素を選択できるようにしようという考えです。
今回は上記のステップ3までを行うコードを見た後、ステップ4以降でどんな処理を行うのか、そのひな型となるコードを実行してみましょう。
まずは分かち書きテキストから形態素とインデックスとの間で相互に変換を行うための辞書を2つ作成します。1つは形態素からインデックスを求める辞書で、もう1つはインデックスから形態素を求める辞書です。なお、分かち書きされたテキストは次のようなものです。
えたい の 知れ ない 不吉 な 塊 が 私 の 心 を 始終 圧え つけ て い た 。
焦躁 と 言おう か 、 嫌悪 と 言おう か ― ― 酒 を 飲ん だ あと に 宿酔 が ある
よう に 、 酒 を 毎日 飲ん で いる と 宿酔 に 相当 し た 時期 が やっ て 来る 。
それ が 来 た の だ 。
これ は ちょっと いけ なかっ た 。
結果 した 肺尖 カタル や 神経衰弱 が いけ ない の で は ない 。
また 背 を 焼く よう な 借金 など が いけ ない の で は ない 。
この内容を含んだテキストファイル(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
ここで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関数で復元したもの、それらに対応する元テキストを表示しています。どうやらうまくインデックス列に変換したり、それをテキストに復元できたりしているようです。
ここまでは純粋に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])
ここでは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]
実行結果は次のようになります。
実行結果を見ると、訓練データである1つ目のテンソルと、正解ラベルである2つ目のテンソルが1つズレていることが分かります(各テンソルのデータ数はmax_lengthで指定した値よりも1つ少なくなります)。
これでニューラルネットワークに入力するデータの準備が完了したといえるでしょう。ここでストレートにPyTorchのニューラルネットワークモジュールの定義に進んでもよいのですが、実際にはニューラルネットワークモデルに上に示したようなデータを入力したときに、最終的にどのような出力が得られるのかを段階を踏んで見てみることにしましょう。
Copyright© Digital Advantage Corp. All Rights Reserved.