初心者が深層学習で小説の文章から感想を生成しようとした話

#はじめに
小説の文章から内容を考慮した感想を自動生成したいと思い、ちょうど機会があったので挑戦してみました。半年ぐらいで「ゼロから作るDeepLearning」の１と２を一通り読み、残り半年で実装しました。せっかく頑張ったので記録用に書いてます。

#作業の流れ
次のような順番で実装しました。

* データ収集
* データの前処理
* 学習
* 生成

これから順を追って説明していきます。

#データ収集
学習するためには大量のデータが必要です。モデルには本文と感想をペアにして入力するので、小説の本文と感想をセットで入手する必要があります。今回は「[小説家になろう](http://syosetu.com/)」と「[カクヨム](https://kakuyomu.jp/)」からスクレイピングして集めました。

##小説家になろう
小説家になろうでは最近(2019年ぐらい？)から作品の一話ずつに感想が書き込めるようになりました。そこで本文ページとその感想ページを行き来するクローラーを作成しました。

* 作品ページ
    * 本文ページ
        * 感想ページ
    * 本文ページ
        * 感想ページ

簡単に説明すると、上のような構造になっているので、作品ページのURLから一話の本文ページのURLを取得し、本文データと感想ページのURLを集め、感想ページの感想データを収集する。これを本文ページの次の話のリンクがなくなるまで続け、終わったら次の作品ページという様に繰り返す。

なろうは静的なサイトでhtmlの構造もわかりやすかったので簡単に集めることができました。本文データはあとで一文づつに分割したいので改行をすべて「/」に変換して一文にし、本文データと感想データが一対一になるように保存しました。

PythonはBeautifulSoupのようなライブラリが充実しているので、簡単にスクレイピングできます。ヘッダー情報としてユーザエージェントがいるので、忘れないようにしましょう。

データは小説の本文を約2.5GBほど集めました。

## カクヨム
小説家になろうの一話ごとに感想が対応するシステムは比較的最近ついた機能なので、データ数が足りなかったのでカクヨムからも集めました。カクヨムは小説家になろうとは違い、動的なサイトであるため、感想の欄（カクヨムでは応援コメント）をクリックして読み込む必要がある。そこでseleniumを用いて感想欄を読み込んでからhtmlを読み込むようにしてデータを集めました。

こちらは約1GBほど集めました。

#データの前処理
現在の深層学習モデルではTransformerを用いても小説全文を入力することはできません。そこで入力に使うデータは感想に対応する数文をとってくる必要があります。また、スクレイピングして集めてきた感想データには学習データとして不適切なものも混ざっています。前処理の流れはざっくりと次のようになります。

* 本文から感想に対応する数文を取ってくる
* テキストデータを掃除する

## 本文から感想に対応する数文を取ってくる
感想は小説全体を指しての感想もあると思いますが、今回は一話の一部分に対しての感想を考えます。感想は小説の一部分を参考にして書くとすると、感想に出てきて小説にも出てくる単語は感想を書く上で重要な単語のはずです。よって、感想で出てくる単語を重みづけして、それを利用して数文取り出すようにします。

そこで使うのがTF-IDFです。TFはTerm Frequencyを表し、これは単語の出現頻度のことです。IDFはInverse Document Frequencyで、これはある単語が含まれる文書の割合の逆数のことです。TF-IDFはTF値とIDF値をかけ合わせて計算します。TF-IDFはその文章で出現回数は多いが、他の文書では出現していない単語のTF-IDFの値は大きくなり，それ以外の単語についてはTF-IDF値は相対的に小さくなります。簡単にいうと、頻度を考慮して単語毎の重要度を算出することができます。つまりは、その文章の中でよく出る人物名などの固有名詞の重要度を上げることができます。ちなみにTF-IDFの計算はsklearnにTfidfVectorizerがあるのでこれを使いました。

単語の分割にはMeCabを使用しました。MeCabはオープンソースの形態素解析エンジンで日本語を形態素に分割するツールです。こちらもmecab-python-windowsを使用するとPython上で利用できます。

さて、TF-IDFで単語の重みづけができたら、本文データを一文づつに分割して学習に使う数文を選びます。今回は三文選ぶことにします。一文づつに分けたものをさらにMeCabで単語に単語に分割して、一文ごとに単語の重みを合計します。このままでは長い文ほど合計が高くなる可能性が高くなってしまうので、長さで正規化します。この数値が大きい三文を用いて学習します。Transformerで学習するとき、複数文をつないでいることがわかるようにタグでつなぎます。

文[タグ]文[タグ]文

これで上のようになります。

## テキストデータの掃除
スクレイピングで集めてきた感想データには「更新ありがとうございました」や「次回も楽しみにしています」のような本文の内容には関係のない感想や誤字報告などが混ざっています。これらはどの小説でも一定数存在しているので何もせず学習すると、本文と感想との対応を学習しづらくなり適切に学習できません。
そこで今回は次の２つで掃除しました。

* 感想の文章の長さが短いものを外す
* 本文に関係のない単語が出ている文を外す

まず、感想の文章の長さが短いものを外すのは、短いものはそもそも内容に触れていないものが多いと感じたからです。内容に対する感想を出力したいのでこれは外します。
次に、本文に関係のない単語が出ている文を外すことに関してです。こちらも内容に対する感想を出力するために邪魔になるので排除します。本文に関係のない単語は「更新」、「誤字」、url、「次回」、「著者」、「書籍化」などいくつか実際に感想を見て、いらないと思った単語をリストにしました。MeCabで分割してこの単語を含む文は使わないようにしました。

##前処理の流れ
最後に前処理流れをもう一度まとめます。

* 感想のTF-IDFを取り、単語に重みづけする
* このとき、データの掃除も行う
* 本文を一文づつに分割し、一文ごとに出てきた単語の重みの合計を求める
* 長さで正規化する
* 数値の大きい三文を取りだし、タグでつなぐ

#学習

いよいよ学習です。学習する上での方針は「本文に出現する単語を感想にも出現させたい」ということです。普通の翻訳モデルでは入力と出力の単語は別々にされています。翻訳モデルでは日本語と英語のようにはっきりとわかれているので単語が混ざってはいけないからです。そこで今回は要約モデルを使います。要約モデルを使うことで単語辞書やembeddingを入力と出力で共有することができます。これによって小説に出てきた登場人物や固有名詞を感想に出現させることができるようになることを期待しています。

今回モデルを自分で一から作るのは厳しかったので、オープンソースの機械翻訳エンジンである[openNMT](https://github.com/OpenNMT/OpenNMT-py)を用いました。特にpytorchを使ったopenNMT-pyを使用しています。ドキュメントは[こちら](https://opennmt.net/OpenNMT-py/)です。こちらを使うことでモデルを一から作ることなく、手軽に高性能なモデルを使えます。便利ですね。デフォルトでは機械翻訳用なのですが、パラメータを設定することで要約モデルでの学習もできます。モデルは最近ではRNNよりTransformerの方が高性能で主流のようなので、Transformerを使います。Transformerの説明は[こちら](https://qiita.com/halhorn/items/c91497522be27bde17ce)か[こちら](https://qiita.com/omiita/items/07e69aef6c156d23c538)あたりがわかやりやすいと思います。

学習データは前処理を行った本文と感想のペアを約18万ペア用います。

さて、ではまずopenNMT-pyの前処理を行っていきます。コマンド一つで簡単にできます。

```
onmt_preprocess -train_src data/train_body.txt -train_tgt data/train_comment.txt -valid_src data/valid_body.txt -valid_tgt data/valid_comment.txt -save_data naro -src_seq_length 400 -tgt_seq_length 400 -dynamic_dict -share_vocab
```

これだけで学習用の語彙データが作れます。特徴的なのはdynamic_dictとshare_vocabです。dtnamic_dictは生成時に入力された文も辞書に加えて生成するようにします。これによって、生成時に入力された文から単語を取り出して出力に出現させることが可能になります。share_vocabは入力データと出力データの辞書データを共有します。翻訳モデルの場合では日本語と英語のように混ざってはいけないが、今回は入力と出力が両方日本語で混ざっても大丈夫なので、辞書データを共有することで語彙数を増やしつつ、共通する語を省けるので動作を軽くできます。

次に、作った語彙データを用いて学習していきます。

```
onmt_train -data naro -save_model naro-model -layers 8 -rnn_size 1024 -word_vec_size 1024 -transformer_ff 4096 -heads 8 -encoder_type transformer -decoder_type transformer -position_encoding -train_steps 300000 -max_generator_batches 2 -dropout 0.1 -batch_size 4096 -batch_type tokens -normalization tokens -accum_count 4 -optim adam -adam_beta2 0.998 -decay_method noam -warmup_steps 8000 -learning_rate 0.01 -max_grad_norm 0 -param_init 0 -param_init_glorot -label_smoothing 0.1 -valid_steps 2500 -save_checkpoint_steps 2500 -world_size 2 -gpu_rank 0 1 -share_embedding -copy_attn
```
 今回はGPU2台を用いて学習しました。オプションの詳しい説明はドキュメントを見てください。特徴的なのはshare_embeddingとcopy_attnです。share_embeddingはそのままの意味でencoderとdecoderでembeddingを共有するようにします。これもshare_vocabと狙いは同じで、embeddingを共有することで一つのembeddingで計算できるので動作を軽くできます。copy_attnはdynamic_dictと対応しています。dynamic_dictと合わせることで生成時に入力の単語を出力に出現させることができるようになります。

#生成
学習ができれば、実際に生成します。しかし、学習用データと同様に、入力に小説全文を使うことはできないので、生成の入力データ用に適切に数文を選ぶ必要があります。その後、openNMT-pyのtranslateを用いて生成します。

##生成用入力データの作成
当然ですが、ここでの入力データは学習に使った入力データとできるだけ近い構造でないとモデルが学習できていても、生成時にうまく生成することができません。そこで、生成用の入力データは学習用の入力データに近づける必要があるのですが、学習用の入力データは出力データである感想に対して取り出しています。生成時には小説データしかないので、これだけで学習用の入力データに近づける必要があります。

ここで、学習用の入力データは感想中の単語を重みにして選んでいるので、小説から選ばれる３文は似たような単語、文章になっていると考えました。そこで、生成用の文章でも三文が似たような単語、文章になるようにします。ここでも前処理で使ったTF-IDFを用います。入力データの作成の流れは次のようになります。

* 一文ずつに分けた小説データに対してTF-IDFを取り、単語に重みづけをする
* その重みを用いて、合計値が最も大きい一文を選ぶ
* 一文ずつに分けた小説データをベクトル化し、コサイン類似度をとる
* TF-IDFの最も大きい文とコサイン類似度が大きい２つはタグでつなぐ

TF-IDFで単語に重みづけをし、まずは一文を決めます。その後、それに似た文を選ぶためコサイン類似度で似たものを探します。コサイン類似度はベクトルがどれだけ同じ方向に向いているかを計算します。自然言語処理では単語をベクトル化することでコンピュータで扱えるようにし、うまくできればベクトルの足し引きで単語を推測できることも知られています。これを用いることで最初に選んだ一文と似ているであろうと思われる二文を選び、生成用の入力データにします。

##openNMT-pyによる生成
前処理、学習と同じでコマンド一つで生成できます。

```
onmt_translate -gpu 0 -batch_size 10 -model data/naro-model_step_62500.pt -src data/test.txt -output pred.txt -min_length 15 -verbose -stepwise_penalty -coverage_penalty summary -beta 5 -length_penalty wu -alpha 0.9 -block_ngram_repeat 2 -ignore_when_blocking "." "[SEN]" "。" -replace_unk
```
いろいろと書いてますが、簡単にいうとビームサーチで生成するということと長さや繰り返しにペナルティを与えていることが書いています。詳しくはドキュメントを見てください。

#結果
お待ちかねの結果です。いくつか生成した例を載せます。

```test.txt
「 しかし 、 あの よう な やり方 で 一方 的 に 公爵 令嬢 が 排除 さ れ た と なる と 、 他 の 貴族 家 も 安泰 で は 無くなり ます 。 すると その 先 に 待つ の は 王 太子 殿下 と 第 二 王子 殿下 の 継承 権 争い です 。 過去 の 歴史 を 振り返っ て も この 争い は 非常 に 危険 で 、 多く の 血 が 流れ 、 国 は 乱れる こと でしょ う 」  [SEN]  「 ですが 、 そんな 決闘 で も 決闘 です 。 あの まま アナスタシア 様 が ご 自身 で 決闘 に 臨ま れ た 場合 、 アナスタシア 様 に 殿下 を 傷つける こと は でき ない でしょ う から 、 おそらく 敗れ た はず です 」  [SEN]  「 さて 、 まず 昨晩 は 娘 の 代理人 として 王 太子 殿下 や クロード 王子 と 決闘 を し て くれ た そう だ な 。 この 点 について は 礼 を 言お う 。 アレン 君 、 ありがとう 」 
```
```pred.txt
王 太子 と 第 二 王子 の 継承 権 争い が 勃発 し そう です な
```
これは小説の内容を言い換えして、語尾を変換したような感想になっています。

```test.txt
つまり 、 フロレスクルス 公爵 家 として は 、 ナゼルバート 様 が 王女 の 伴侶 に なっ て も 、 ロビン 様 の 子供 が 次 の 国王 に なっ て も 、 どちら でも 良く て 、 「 できれ ば 、 御し やすい 王女 殿下 と ロビン 様 の 子供 が 王 に なっ て くれ た 方 が いい かも 」 なんて 考え を 持っ て いる という こと だ 。  [SEN]  「 俺 を 正式 な 夫 に 、 ロビン 殿 を 愛人 として 迎え入れる ん だって 。 で 、 ロビン 殿 と ミーア 王女 の 子供 が 男 だっ たら 、 その 子 が 次 の 王 だ 」  [SEN]  　 もともと 、 ナゼルバート 様 と 結婚 する の を 条件 に 、 王女 殿下 は 次期 女王 に なる こと を 認め られ て い た らしい 。 というのも 、 王女 殿下 は 全く 政治 の 勉強 を し て い ない から だ 。 
```
```pred.txt
王 太子 は ロビン を 傀儡 に する つもり だっ た の か な ？
```
これも、小説の内容を言い換えている感想になります。個人的に入力で出てこない傀儡という単語が出てきたのが面白いと思います。しかし、名詞の当てはめで間違っています。

```test.txt
帝国 と バークレイ 産 の 麦 の 取引 、 及び レナート と マリ アベル の 婚姻 に 際 する 条件 について は 、 予定 さ れ て いる 二 人 の 運命 の 出会い の 前 に 条件 を 詰める こと に なっ て いる 。  [SEN]  　 その間 に 、 マリ アベル は 帝国 独自 の マナー など を 教え て もらっ た 。  [SEN]  　 帝国 の 皇太子 と も なれ ば 、 その 予定 は びっしり と 詰まっ て いる こと だろ う 。
```
```pred.txt
マリ アベル と 皇太子 の 仲 が 進展 し そう な 気 が し ます
```
感想でよく出現するテンプレパターンです。

```test.txt
「 とりあえず この 辺り で 肩 慣らし を し て … … 単純 に リエル が 戦闘 に 慣れる 練習 を しよ う か 。 ファースト スキル の 死ん だ フリ も 何 度 も 使っ て いれ ば ドンドン 精度 や スキル 技能 が 向上 する はず だ 。 まずは 死ん だ フリ を 強化 し て 行こ う 」  [SEN]  　 どこ の 世界 に 死ん だ フリ の 技能 を 向上 する ため に 修練 を する なんて 話 が ある の だろ う か 。  [SEN]  「 リエル 、 今 一 度 君 の 最強 スキル 、 死ん だ フリ と 真剣 に 向き合っ て ほしい 」 
```
```pred.txt
「 とりあえず この 辺り で 肩 慣らし を し て … … 単純 に リエル が 戦闘 中 に 死ん だ フリ を しよ う か 。 ファースト スキル の 死ん だ フリ も 何 度 も 使っ て いれ ば ドンドン 精度 が 向上 する はず だ 。 」 この 部分 の 最強 スキル だ と 思っ た
```
なんと本文を引用して、感想を出力しています。

ここまで見ると、それなりの感想が出力されているように見えますが、これは少数のよさそうなものを取ってきた例です。実際はほとんどのものは日本語がおかしかったり、名詞の当てはめで間違っていたりしています。ダメな例をいくつか載せておきます。

```pred.txt
ロビン が 腕 を 飲む こと は 出来 ない ん じゃ ない か な ？
エレイン と エレイン の コンビ は 結構 好き な ん じゃ ない か な 。
シャナル の 「 シャナル さん 、 人質 って こと に なっ て もらっ て いい よ 」
エルフ たち は エルフ の 国 の こと を 知っ て いる の でしょ う か ？
剣 聖 の 剣 と 剣 の 関係 性 が 出 て き まし た 。
犬 人 型 の 犬 、 猫 耳 と 尻尾 を 装着 し て ます
アスラ かっこいい ！ グンドウ と グンドウ を 回収 し まし た ね ！
```

ですので、うまくできたモデルを作れたという結果にはなりませんでした。

#簡単な考察
##語尾の偏り
生成した感想では語尾に一定のパターンができていた。例えば、「かな？」「気になります」「でしょうか？」「良いのでは？」のようなものです。前者２つは人間が書く感想でもよく見られる語尾なので良いのですが、「気になります」が多すぎる問題があります。「～と～の関係が気になります！」のような文がよく生成されます。これは学習データにある感想にこのような文が多かったのだと思われます。後者２つはおそらく誤字を指摘するコメントから来たものだと考えています。小説家になろうやカクヨムの感想ではそういった感想ではなく誤字を指摘するコメントが見られます。一応「誤字」といったキーワードで排除したのですが、排除できていないものが多そうなので、確実に排除する必要があります。

##感想の傾向
出力された感想はいくつかのパターン当てはまってるように感じました。

まず本文の内容を言い換え、語尾を変換したものです。これは「～ですよね？」のような疑問形や確認する形に変換されている例をよく見ます。


次に、「～気になります」、「～が好きです」、「～と～の関係」、「～の今後」のようなテンプレに対して単語を当てはめたものです。これは実際の感想でもよく見られるので単語の当てはめがうまくいけば感想っぽく見えます。しかし、実際に単語の当てはめがうまくいっているものは少ないです。～と～のような並列の単語当てはめでは、まったく関係がない２つを選んだり、２つとも同じ単語が入ったりすることが頻出します。

最後に、本文から引用して一言のパターンです。これは入力データから文を取ってきて最後に何か一文付け加えているものです。引用しているので違和感があれば目立ちます。これは誤字指摘のコメントから来ていると考えています。相当する一文を引用して、ここがこっちのほうがいいと思います、という内容の誤字指摘から一文引用と一言というパターンができたと考えました。これも誤字を指摘するコメントの排除が不十分だったことを表しているので、前処理が課題になっていることがわかります。

たまに出現する変わったものとしてメタ的な感想を出力することがあります。例えば、「ヒロインは～ですね」みたいな内容です。モデルでは実際に内容を理解しているわけではないので、名詞の当てはめに間違う確率が高く、間違っていると目立ちます。でも、合っていると人間が書いた感想のようになります。

#課題
今回の課題点をまとめてみました。

* 全体的に日本語がおかしい
    * 要モデルの改善
* 名詞の当てはめ
* 特定の語尾に偏り
    * 前処理での排除
* 感想に関係がない単語の排除
    * 前処理での排除
* データ数
    * そもそも学習データが減るため思い切った前処理ができなかった
    * 前処理で徹底して排除するにはより多くのデータが必要

#まとめ
要約モデルを用いて小説の本文から感想の出力を行いました。全体的な性能としてはよいとは言えませんが、一部でも感想らしいもの生成されたのでよかったです。一番大きな課題は名詞の当てはめです。有識者に聞いた限り、名詞の当てはめ問題はかなり大きなモデルを使っても起こるらしいので難しいそうです。

今後は、データ数を増やし、質のいい学習データを作り、モデルの改善を行っていきたいと考えています。前処理での工夫余地はあって、タグを増やしてネガティブ、ポジティブで分類したり、本文以外の情報、例えば登場人物やあらすじを入力データにくっつけるなども考えています。

うまくできたらTwitterBotや小説の内容を考慮した対話システムも作ってみたい（願望）。

自然言語処理や深層学習に触る体験ができたので自分の中では割と満足しています。


＜追記＞
このモデルを使って感想風の文をTwitterに定期的に投稿するボットを作りました。
https://twitter.com/ImpressionNaro