RNNが太宰治風の文章を自動生成してくれた―RNNで文章生成〈10〉

はじめに

前回の記事↓

自作言語モデルをPyTorchで実装してみる―RNNで文章生成〈9〉

前回はPyTorchを使った文章生成モデルの実装の説明をしました。今回はモデルの学習が完了したのでいよいよ太宰治風の文章を生成していきたいと思います。まず生成結果を紹介し、その後、前回に続いて文章生成の実装の説明を書いていきます。

作成した文章生成モデルはJupyter Notebook形式でGithubにまとめてあります。↓

mitaka1962/dazai-text-generation (Github)

文章生成の結果

早速、学習済みのモデルが自動生成した太宰治風の文章を見てみましょう。言語モデルはRNNの一種であるLSTMを使っており、青空文庫にある太宰治の小説を使用して学習させたものです。「私は」という入力を与えてそれに続く文章を生成させます。結果はコチラです。

私は、薄情な、外国の古典までの、成績を、信頼すると同時に、そうしてその後に、また一枚、顔が白くて、伴うのものなぞ過ぎています。

僕はもう、いくら未だきらいなんですね。

誰にも自信が無いんです。

女には、この創作遊覧が私の理想名詞というところになる。

しばらくしても民衆の嘆きもつまらなくなりました。

「愛しているんですよ。

僕は、いいから、あんな工合いなものをおっしゃるように、と驚きますからね。」

と言い、それを借りているのである。

「四人と五いうのがわかっているような事ばかり書いていますよ。」

私には善い文才が欲しかった。

うーん、微妙だなあ。。。

語尾のですます調や「自信がない」、「民衆の嘆き」、「女には」、「愛しているんです」といったフレーズ等々、太宰っぽさを感じさせる部分はかなりあります。しかし、全体的に見るとなんだかよくわからない文やセリフが生成されてしまっています。また文章全体の整合性というものが欠落していて、全体的なストーリーや文の主題が読み取れません(何となく創作に関する文章のような気がしないでもないかも?)。

とはいえ、動詞の活用やそれに続く助詞の使い方、かぎ括弧の使用など文法面においてはかなり正確に学習しています。これは学習前のモデルによって生成された文章と比較するとよくわかります。学習前の状態で生成される文章はコチラです。

私は札ランスロット天外漫才tc電話機ケント慣れ親しん新品1人把淘汰ロマちゃん砕いrpmバスケット概説乱歩相当現実おとし孤児院乗り心地ナース揶揄苦手精霊太っ腹表向き織りカツ恐るにこやか...(中略)...新羅上履き成功ネタミッキーたかじん小児いただきものアメンバー定まっ経理留袖http気概ラベンダー遠ざけ見付けデオチャイナ取締役会フード他方

全く学習していないので、知っている単語をただ並べただけの文章とも呼べない代物になってしまっています。この状態から最初に紹介した文章が書けるようになったということを考えると、文法面はかなり学習できていることがわかります。

もう一つ、「恥の多い生涯を送ってきました。」という入力の後にどんな文章が生成されるか見てみましょう。

恥の多い生涯を送って来ました。

作者が、東京の海から、引越したって、そうでも笑いませんでしたので、僕は、やはりもう、あの子を、奥さんと愛してけれども、僕にも逢いませんでした。

真になって、それこそ、それだけだ、と私は眼をつぶって、「なんにも言えない。

君らも、おわかりなんじゃないんだよ、うちでフランスで約束していたじゃないか。

僕は、自分をあきらめているのだから。

僕が気の毒な人間というものを紹介してくれ。」

「これは、妙なところだ。」

「責任を得ないだろう。」

「勉強しなければいけないもんですか。」

と私は、少し感じる口調で呟いたのである。

「笑わない、考えるものだかすべてならんじゃないか。」

「よくは、あるけどね。」

と列には、腹の不安が一杯と来ました。

やっぱり全体的に見て意味がわからない文章になってしまいました。残念ながら人間の生活に対する恐怖は語ってくれませんでした。

条件ごとの学習結果の比較

前回の続きから説明していきます。学習済みの重みの使用や重み共有が学習結果にどのようにはたらいているかを知るために、様々な条件でモデルを学習させて結果のパープレキシティを比較してみました。学習後のパラメータはtorch.save()で別々のファイルに保存します。

モデル初期化の際の設定は以下のような6種類が考えられます。前回と変わって、重み共有の有無を決定するweight_tiedを引数に追加しました。

# 1.標準
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout, weight_tied=False)
# 2.重み共有
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout, weight_tied=True)
# 3.学習済み単語ベクトル(可変)
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout,
               embeddings, freeze=False, weight_tied=False)
# 4.学習済み単語ベクトル(可変)+ 重み共有  
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout,
               embeddings, freeze=False, weight_tied=True)
# 5.学習済み単語ベクトル(不変)
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout,
               embeddings, freeze=True, weight_tied=False)
# 6.学習済み単語ベクトル(不変)+ 重み共有  
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout,
               embeddings, freeze=True, weight_tied=True)

それぞれのモデルにおけるテストデータに対するパープレキシティと、学習済みパラメータの保存先は以下の通りです。

種類 パープレキシティ 保存先
1.標準 43.93 weight_normal.pth
2.重み共有 44.76 weight_tied.pth
3.学習済み単語ベクトル(可変) 43.85 weight_emb.pth
4.学習済み単語ベクトル(可変)+ 重み共有 43.94 weight_emb_tied.pth
5.学習済み単語ベクトル(不変) 71.49 weight_emb_freeze.pth
6.学習済み単語ベクトル(不変)+ 重み共有 218.25 weight_emb_freeze_tied.pth

正直、今回は学習済みの重みを使ったり重みを共有したりという技法によって結果の良さが劇的に向上するということは無かったです。やはり学習の種類によってその効果の度合いが変わってくるのでしょうか。chiVeをnumpy配列に変換したりとかなり苦労して学習済み単語ベクトルを導入したので少し残念ですが、まあ仕方ないでしょう。また、重みを固定した場合は学習がうまくいかなくなりました。他に、重み共有の場合は学習するパラメータが少なくなるため、1エポック当たりの学習時間が少し減少するという利点がありました。

これ以降は、学習済み単語ベクトルと重み共有を使って学習したパラメータであるweight_emb_tied.pthを使って文章生成をしていきます。

文章生成の実装

言語モデルを使って文章を生成するプログラムの実装について説明します。前回のコードの続きなので変数なども受け継いでいます。

文章生成の手順は次の通りです。まずいくつかの単語IDをモデルに入力して、その出力から次の単語IDを予測します。そしてさらにその予測された単語IDを入力にして次の単語IDを予測します。これを繰り返すことで単語IDの配列、すなわち文章を作っていきます。次の単語IDを予測する段階で決定的に一つの単語IDを選ぶ方法と、確率的に選ぶ方法があります。

まず、これらの処理を関数として実装します。引数はそれぞれ、学習済みの言語モデルmodel、最初の入力となる単語ID配列start_ids、出力される単語ID配列の長さlength、出力しない単語IDのリストskip_ids、確率的に選択するか否かを設定するprob、確率的に選択する際の乱数生成のシード値seedです。戻り値は生成された単語IDのリストであるword_idsです。

def text_generate(model, start_ids, length=200, skip_ids=None,
                  prob=True, seed=2020):
  word_ids = []
  word_ids += start_ids

  random.seed(seed)
  model.eval()
  with torch.no_grad():
    hidden = None
    input_id = start_ids
    while len(word_ids) < length:
      input = torch.tensor(input_id, dtype=torch.long,
                           device=device).view(1, -1).t().contiguous()
      output, hidden = model(input, hidden)
      
      # outputは(時系列長, バッチサイズ=1, 語彙数)
      p_list = F.softmax(output[-1].flatten(), dim=0)
      # 確率的に選択
      if prob:
        while True:
          rnd = random.random()
          p_sum = 0
          for idx, p in enumerate(p_list):
            p_sum += p.item()
            if rnd < p_sum:
              sampled = idx
              break
          # skip_idsに含まれる時はやり直し
          if (skip_ids is None) or (sampled not in skip_ids):
            break
      # 決定的に選択
      else:
        if skip_ids is not None:
          p_list[skip_ids] = 0
          sampled = p_list.argmax().item()

      word_ids.append(sampled)
      input_id = sampled
    
  return word_ids

まずmodel.eval()モデルを評価モードにした後、勾配計算の必要が無いのでtorch.no_grad()を記述しています。その後は単語ID配列の長さがlengthになるまでひたすら入力と予測を繰り返します。

モデルへの入力はtorch.tensorでPyTorchの配列に変換しています。このとき、view(1, -1)で二次元配列にし、さらにt()で転置して(時系列長, バッチ数=1)の形に直しています。転置の後はcontiguous()でメモリ上の位置を連続にする必要があります。

モデルからの出力のうち、時系列順にもっとも後ろのものをSoftmax関数で正規化すると語彙数分の長さをもつ配列が得られます。この配列の要素はそれぞれ、そのインデックスに対応する単語IDがこれまで入力された単語IDの次に来る確率を表しています。つまり、この配列に従うと次の単語IDを選ぶことが可能になるわけです。

prob=Trueの時は得られた確率の配列を使った重み付きランダムサンプリングによって、確率的に選びます。numpyではchoice()という重み付き選択の関数があるのですが、p_listはPyTorchの配列なので使えません。そこで自作のコードで重み付き選択を行ってます。いろんなアルゴリズムがあるそうですが今回は簡単なものを使いました。コチラに詳しく載っています。↓

重み付きランダムサンプリングアルゴリズム (Qiita)

for文でp_listから取り出されているpはスカラですがtorch.Tensor型になっているので、p.item()でスカラ値を取り出しています。

prob=Falseのときは、単純に確率が最も大きいものを選択します。コチラもp_list.argmax()のあとitem()でスカラ値を取り出しています。

skip_idsに含まれる単語IDの選択を避けるために、確率的選択の際はwhileifbreakを使った方法で、決定的選択の際は確率値を0にする方法で対応しています。

このようにして定義した関数を使って実際に文章生成するコードがコチラです。

# モデルの学習済みパラメータをロード
save_path = 'drive/My Drive/.../weight_emb_tied.pth'
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout, weight_tied=True)
model.load_state_dict(torch.load(save_path))
model.to(device)

# 最初の単語(列)を指定
start_words = ['太宰']

# 辞書に存在するか確認して単語IDへ
start_ids = []
for start_word in start_words:
  if start_word not in word_to_id:
    raise KeyError(start_word + ' is not in the dictionary!')
  start_ids.append(word_to_id[start_word])
skip_ids = [word_to_id['']]

word_ids = text_generate(model, start_ids, skip_ids=skip_ids)
text = ''.join([id_to_word[w_id] for w_id in word_ids])
text = text.replace('。', '。\n').replace('。\n」', '。」\n')
print(text)

初めに学習時に保存したファイルをmodel.load_state_dict(torch.load(save_path))で読み込みます。次に最初の入力となる単語または単語列を指定し、単語IDに変換します。skip_idsには<unk>を指定し、<unk>が出力されないようにします。そして、関数で返ってきた単語ID配列を単語に変換し、''.join()で結合します。最後に文章を整形したら出力します。

これでひとまず文章生成の実装は完成です。最初に紹介した「私は」から始まる文章は次のコードで出力しています。ただし各学習ごとに最終的なモデルの重みの値は変わってくるので、下のコードと引数の設定が同じでも生成結果が同じになるとは限りません。

save_path = 'drive/My Drive/.../weight_emb_tied.pth'
model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout, weight_tied=True)
model.load_state_dict(torch.load(save_path))
model.to(device)

start_words = ['私', 'は']

start_ids = []
for start_word in start_words:
  if start_word not in word_to_id:
    raise KeyError(start_word + ' is not in the dictionary!')
  start_ids.append(word_to_id[start_word])
skip_ids = [word_to_id['']]

word_ids = text_generate(model, start_ids, length=173,
                         skip_ids=skip_ids, seed=1)
text = ''.join([id_to_word[w_id] for w_id in word_ids])
text = text.replace('。', '。\n').replace('。\n」', '。」\n')
print(text)

出力を決定的に選択する場合、すなわち上記のコードでtext_generate()prob=Falseという引数を追加した場合の生成文はこのようになります。

私は、その時、私の家には、その時、私の家の者たちに、「お酒を飲んで、お酒を飲んでいるのです。」

と言って、「僕は、あなたのお手紙を、お書きになって、あなたのお手紙を、お送りして下さい。」

と言って、「僕は、あなたのお手紙を、お書きになって、あなたのお手紙を、お送りして下さい。」

と言って、「僕は、あなたのお手紙を、お書きになって、あなたのお手紙を、お送りして下さい。」

と言って、「僕は、あなたのお手紙を、お書きになって、あなたのお手紙を、お送りして下さい。」

なぜか文章を繰り返す出力になりました。他の入力で試してみても似たような結果、すなわち繰り返しが続くような文が生成されました。決定的な選択方法だとうまくいかないようです。

また、確率的な選択においてより意味の通じる文を生成するためにちょっとした改良も試してみました。続く確率が高い単語の内、上位何個かだけを取り出してその中から選択する方法です。確率が極端に低いものを切り捨てるので文脈的におかしい単語の選択を避けることができるのではないかと思い、試してみました。新たな引数topを追加しています。これは上位何個を取り出すかを設定する引数です。

def text_generate(model, start_ids, length=100, skip_ids=None,
                  prob=True, top=None, seed=2020):
  
  # (略)
      
      p_list = F.softmax(output[-1].flatten(), dim=0)

      if top is not None:
        # 降順に並べ替えて上位だけ取り出す
        sorted_p_list = p_list.sort(descending=True).values[:top]
        sorted_idx = p_list.sort(descending=True).indices[:top]
        # 正規化
        p_list = sorted_p_list / sorted_p_list.sum()

      if prob:
        while True:
          rnd = random.random()
          p_sum = 0
          for idx, p in enumerate(p_list):
            p_sum += p.item()
            if rnd < p_sum:
              # 並べ替えた場合は元のインデックスに戻す
              sampled = idx if top is None else sorted_idx[idx].item()
              break
          if (skip_ids is None) or (sampled not in skip_ids):
            break

選択を上位10単語に絞って、すなわちtop=10に設定して先ほどの「私は」の入力を試してみると結果はこうなりました。

私は、いままで、私のほうを見ると、その夜、それっきり、私を見ていた、などと、私には思われる。

私がこのようなお手紙を読んでいたのである。

私は、私から、その事には、その事に就いては私の家であると、そのときには私は、その時、私にも何も知らず、またその頃は、それでも、それを、私のほうへ見て、私がいまのその日の「思い出」という言葉を、「どうしたのよ」と言っていたが、その時はその夜、私は、私の家に帰った。

「何の事も、僕を知らぬ。

どうしても、

怒涛の「私」ラッシュ。。。

決定的に選んだ場合と類似して、似たような単語の繰り返しが多くなりました。やはり単純に確率が高いものの中から選択するというだけではダメなようです。

top=100にして上位100単語から選択する方法にすると、以下のようになりました。

私は、たいへん弱いものだ。

どうやら、誰かも、何を言ったってかまわない。

これくらいの事は、僕には、ちっともわかって来ないのだぞ。

僕の人格というところが、まるでわかるからだ。

私には、とてもよく書けない。

ただ、この女の小説全部の事を言ったら、何がわからないかしら。

その人たちたちは、誰やらこの二階にはいってから、その家に、「この小説の事を、ちっとも心配で知らなくて、ここに来ているのだ。」

私は、ひどく不機嫌な顔をして、「女は、どうですか。

あんな話は、お前に、こんなところにもの。」

自分は涙が出て来る。

それだけは、たいへんそれは、

文の繰り返しはなくなりましたが、改良前とあまり大差ない結果になりました。

最初に紹介した「恥の多い生涯を送ってきました。」から始まる文章は以下の設定で生成したものです。

start_words = ['恥', 'の', '多い', '生涯', 'を', '送っ', 'て', '来', 'まし', 'た', '。']

word_ids = text_generate(model, start_ids, length=222,
                          skip_ids=skip_ids, top=2000, seed=1)

ちなみに学習前のモデルで出力させたいときは、以下のように保存したパラメータを使用せずにインスタンス化することで可能です。

# save_path = 'drive/My Drive/.../weight_emb_tied.pth'
# model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout, weight_tied=True)
# model.load_state_dict(torch.load(save_path))

model = MyLSTM(vocab_size, emb_dim, hidden_size, dropout)
model.to(device)

おわりに

記事が長くなりそうなのでこの辺で終わって、次回に続きをやります。様々な入力を与えたり、乱数生成のシード値をいじったりして文章生成でいろいろ遊んでみた後に、結果の考察や反省点などについても書いていこうと思います。

気づけばもう10回目の記事ですね。。。

次回の記事↓

完成した太宰治風文章生成モデルで遊んでみた―RNNで文章生成〈11〉