DeZeroで日本語文章生成のコードを書いてみた

#1.はじめに
　今年の４月、**「ゼロから作るDeep Learning3 フレームワーク編」**が発売されました。**ゼロ作**は**１・２**と読んでいて大変勉強になったので、今度はフレームワーク編に挑戦することにしました。

　ということで最近本を購入したのですが、１ステップづつ勉強を始める前に、まず**フレームワークの全体像**をザッと知るために、とりあえず**コード**を書いてみようと思いました。

　[Github](https://github.com/oreilly-japan/deep-learning-from-scratch-3)にある **DeZero** の**ライブラリー** と **example** を参考に、**本**をパラパラ見ながら自然言語処理の簡単なコードを google colab 上で書いてみたので、備忘録として残します。

　なお、コードは Google Colab で作成し Github に上げてありますので、自分でやってみたい方は、この[**「リンク」**](https://github.com/cedro3/DeZero/blob/master/Generate_japanese_text.ipynb)をクリックし表示されたシートの先頭にある**「Colab on Web」**ボタンをクリックすると動かせます。

#2.日本語データセットNekoクラス
　日本語の自然言語処理をやろうとした時に、画像処理の場合の**MNIST**の様に簡単に使えるものがあると便利だなと思って似た様なものを作ってみました。

　作成する**データセットクラス**は、青空文庫の**[「吾輩は猫である」](https://www.aozora.gr.jp/cards/000148/card789.html)**をダウンロードし、余計な部分を削除してから **janome** で分かち書きを行い、辞書とcorpusを作成後、**時系列データ**と**次の正解データ**を作成するものとします。

　事前準備として、`!pip install dezero` で**フレームワーク dezero** をインストールし、`!pip install janome` で**形態素分析ライブラリー janome** をインストールします。

　データセットのクラス名は **Neko** とし、dezeroのお作法通り、**Datasetクラスを継承**し、`def prepare()` に**処理内容**を書き、その後に**処理に必要な関数**を書きます。

```python
import numpy as np
import dezero
from dezero.datasets import Dataset
from dezero.utils import get_file, cache_dir
import zipfile
import re
from janome.tokenizer import Tokenizer

class Neko(Dataset):
    
    def prepare(self):
        url = 'https://www.aozora.gr.jp/cards/000148/files/789_ruby_5639.zip'
        file = get_file(url)  
        data = self.unzip(cache_dir + '/' + '789_ruby_5639.zip')  
        self.text = self.preprocess(cache_dir + '/' + 'wagahaiwa_nekodearu.txt')
        self.wakati = self.keitaiso(self.text)
        self.corpus, self.word_to_id, self.id_to_word = self.process(self.wakati)
        self.data = np.array(self.corpus[:-1])
        self.label = np.array(self.corpus[1:])
    
    def unzip(self, file_path):
        with zipfile.ZipFile(file_path) as existing_zip:
            existing_zip.extractall(cache_dir)
            
    def preprocess(self, file_path):
        binarydata = open(file_path, 'rb').read()
        text = binarydata.decode('shift_jis')        
                   
        text = re.split(r'\-{5,}', text)[2]  # ヘッダの削除
        text = re.split('底本：',text)[0]   # フッタの削除
        text = re.sub('｜', '', text)  # | の削除
        text = re.sub('［.+?］', '', text)  # 入力注の削除
        text = re.sub(r'《.+?》', '', text)  # ルビの削除
        text = re.sub(r'\u3000', '', text)  # 空白の削除
        text = re.sub(r'\r\n', '', text)  # 改行の削除
        text = text[1:]  # 先頭の１文字を削除（調整）
        return text
 
    def keitaiso(self, text):
        t = Tokenizer()
        output = t.tokenize(text, wakati=True)
        return output
     
    def process(self, text):
        # word_to_id, id_to_ward の作成
        word_to_id, id_to_word = {}, {}
        for word in text:
            if word not in word_to_id:
                new_id = len(word_to_id)
                word_to_id[word] = new_id
                id_to_word[new_id] = word

        # corpus の作成
        corpus = np.array([word_to_id[W] for W in text])
        return corpus, word_to_id, id_to_word

```
　**継承**した **Datasetクラス**の**コンストラクタ**(`def __init__()`のところ) には `self.prepare()` と記載されているので、Nekoクラスを**インスタンス化**すると、`def prepare()`が**動作**します。

　`def prepare()`  では、dezero ライブラリーにある `get_file(url)` を使って、指定した `url` からファイルをダウンロードし、`cache_dir` に保存します。google colab の場合、`cache_dir` は `/root/.dezero` です。

　その後、関数を順次４つ呼び出して処理を行います。最後にお作法通り `self.data` (時系列データ)と `self.label` (次の正解データ)に **corpus** を１つズラしで代入します。

　変数 `text, wakati, corpus, word_to_id, id_to_word` のそれぞれに、`self.` を付けているのは、 Nekoクラスを**インスタンス化**したら、**属性**として呼び出せるようにするためです。 

　`def unzip()`はダウンロードした **zipファイルを解凍**する関数。`def preprocess()` は解凍したファイルを読み込み、ルビや改行など**余計な部分を削除**したテキストを返す関数。`def keitaiso()` はテキストを形態素分析し**分かち書き**を返す関数。`def process()` は分かち書きから**辞書**と**corpus**を作成する関数です。

　では、実際に動かしてみましょう。

#3.Nekoクラスを動かしてみる
![スクリーンショット 2020-06-15 08.33.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/45ad4798-9a86-21e5-23fa-009ec8e5b451.png)
　`neko = Neko()`で Nekoクラスを**インスタンス化**するとファイルをダウンロードし**処理を開始**します。janomeの分かち書き処理に少し時間が掛かるため、完了するまで数十秒程度掛ります。完了したら、早速使ってみましょう。
![スクリーンショット 2020-06-14 19.08.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/f8cda99a-fa03-4897-01d7-9d1c3981c047.png)
　`neko.text` で**テキスト**、`neko.wakati` で**分かち書き**、`neko.corpus` で **corpus** が表示できます。テキストはいわゆるベタ打ち、分かち書きは単語単位のリスト、corpus は分かち書きの単語の先頭から数字をふった(重複なし)ものです。ついでに、辞書も見ておきましょう。
![スクリーンショット 2020-06-14 19.16.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/6df47ba1-43ed-8913-e4f8-19d828f128e5.png)
　`neko.waord_to_id[]`は**単語を数宇に変換**する辞書、`neko.id_to_word[]` は**数字を単語に変換**する辞書です。学習データを見てみましょう。
![スクリーンショット 2020-06-14 19.22.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/dafd79fe-9e43-44c0-6f71-ef4588fd00f0.png)
　`neko.data` と `neko.label` は１つズレになっていることが分かります。最後に、data の長さと辞書に載っている単語数を見てみましょう。
![スクリーンショット 2020-06-14 19.54.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/2efced53-b671-da15-4bc5-b342faf1bcc9.png)　
　**dataの長さ** は 205,815個、辞書に載っている単語数 **vocab_size** は 13,616個です。

　それでは、本体のコードを書きます。
#4.本体コード
　**Nekoクラス**を使って**「吾輩は猫である」**の**単語順**を学習し、それを元に文章を生成するコードを書いて行きます。

```python
import numpy as np
import dezero
from dezero import Model
from dezero import SeqDataLoader
import dezero.functions as F
import dezero.layers as L
import random
from dezero import cuda 
import textwrap

max_epoch = 70
batch_size = 30 
vocab_size = len(neko.word_to_id)  
wordvec_size = 650  
hidden_size = 650
bptt_length = 30  

class Lstm_nlp(Model):
    def __init__(self, vocab_size, wordvec_size, hidden_size, out_size):
        super().__init__()
        self.embed = L.EmbedID(vocab_size, wordvec_size)
        self.rnn = L.LSTM(hidden_size)
        self.fc = L.Linear(out_size)

    def reset_state(self):  # 状態リセット
        self.rnn.reset_state()

    def __call__(self, x):  # レイヤの接続内容を記載
        y = self.embed(x) 
        y = self.rnn(y)
        y = self.fc(y)
        return y
```
　モデルは、**EmbedIDレイヤ＋LSTMレイヤ＋Linearレイヤ**のシンプルな構成です。EmbedIDの入力は、単語にふった数字(整数)です。

　**EmbedID**の単語埋め込み行列のサイズは **vocab_size × wordvec_size** なので 13616×650 です。**LSTM** の `hidden_size` は wordvec_size と同じ650です。そして、**Linear**の出力サイズ `out_size` は vocab_size と同じ13616です。

　`def __call__()`に**各レイヤの接続内容**を記載します。ここに記載した内容は、生成したインスタンスに関数の様に引数を与えて呼び出すことが出来ます。例えば、`model = Lstm_nlp(....)` でインスタンス化したら、`y = model(x)` で `def __call__()` の部分が動かせます。つまり、いわゆる predict がこれで実現できるわけです。これはスマートですね。

```python
model = Lstm_nlp(vocab_size, wordvec_size, hidden_size, vocab_size)  # モデル生成
dataloader = SeqDataLoader(neko, batch_size=batch_size)  # データローダ生成
seqlen = len(neko)
optimizer = dezero.optimizers.Adam().setup(model)  # 最適化手法は Adam

# GPUの有無判定と処理
if dezero.cuda.gpu_enable:  # GPUが有効であれば下記を実行
    dataloader.to_gpu()  # データローダをGPUへ
    model.to_gpu()  # モデルをGPUへ
```
　データローダは、時系列データ用の `SeqDataLoader` を使用します。時系列データはシャッフルすると並びが変わってしまうため、時系列データを一定間隔区切って複数のデータを取り出す方式をとっています。

　GPUが使用できる様になっている場合は、`if dezero.cuda.gpu_enable:` が True になるので、その場合はデータローダとモデルをGPUへ送ります。

```python
# 学習ループ
for epoch in range(max_epoch):
    model.reset_state()
    loss, count = 0, 0

    for x, t in dataloader:
        y = model(x)  # 順伝播

        # 次の単語の出現度合い y (vocab_size次元のベクトル)をsoftmax処理したものと正解(ワンホットベクトル)とのロス計算
        # 但し、入力 t はワンホットベクトルの1が立っているインデックスの数字(整数)
        loss += F.softmax_cross_entropy_simple(y, t)  
        count += 1

        if count % bptt_length == 0 or count == seqlen:
            model.cleargrads()  # 微分の初期化
            loss.backward()  # 逆伝播
            loss.unchain_backward()  # 計算グラフを遡ってつながりを切る
            optimizer.update()  # 重みの更新
    avg_loss = float(loss.data) / count
    print('| epoch %d | loss %f' % (epoch + 1, avg_loss))

    # 文章生成
    model.reset_state()  # 状態をリセット
    with dezero.no_grad():  # 重みの更新をしない
         text = []
         x = random.randint(0,vocab_size)  # 最初の単語番号をランダムに選ぶ
         while len(text)  < 100:  # 100単語になるまで繰り返す
               x = np.array(int(x))
               y = model(x)  # yは次の単語の出現度合い(vocab_size次元のベクトル)
               p = F.softmax_simple(y, axis=0)  # softmax を掛けて出現確率にする
               xp = cuda.get_array_module(p)  # GPUがあれば xp=cp なければ xp=np
               sampled = xp.random.choice(len(p.data), size=1, p=p.data)  #　出現確率を考慮して数字(インデックス)を選ぶ
               word = neko.id_to_word[int(sampled)]  # 数字を単語に変換
               text.append(word)  # text に単語を追加
               x = sampled  # sampledを次の入力にする
         text = ''.join(text)
         print(textwrap.fill(text, 60))  # 60文字で改行して表示
```
　学習ループです。`y = model(x)`で**順伝播**し、`loss += F.softmax_cross_entropy_simple(y, t)`でロスを計算します。

　このとき、y は次の単語の**出現度合い**を表す**ベクトル**(vocab_size次元)で、これにsoftmaxを掛け**出現確率**にしたものと**ワンホットの次の正解データ**からロス計算をしています。但し、入力 t はワンホットベクトルの**何番目に1が立っているかを表す数字(整数)**です。

　`if count % bptt_length == 0 or count == seqlen:`で count がbptt_lengthの整数倍か最後まで行ったら、逆伝播し重みを更新します。

　次に、１eopch毎に100単語の文章生成を行います。まず、`model.reset_state()`で状態をリセットし、`with dezero.no_grad():` で重みを変化させないようにします。そして、`x = random.randint(0,vocab_size)` で単語の初期値を 0〜vocab_sizeまでの整数からランダムに決めて、次の単語を予測します。その予測した単語を元にさらに予測することを繰り返し文章を生成します。

　`p = F.softmax_simple(y, axis=0)`は、y にsoftmaxを掛けて次の単語の出現確率を求め、`xp.random.choice()`でその出現確率に沿ってランダムに単語を選んでいます。

　`xp.random.choice()`の先頭が**xp**となっているのは、先頭の文字をCPUで動かす場合は **np**(numpy)、GPUで動かす場合は **cp**(cupy) に変える必要があるためです。そのため、`xp = cuda.get_array_module(p)`で判定してCPUならxp=np、GPUならxp=cpを代入します。 

　それでは、本体を動かしてみましょう。

#5.本体コードを動かしてみる
　本体コードを実行すると、「吾輩は猫である」の単語順を学習し、1epoch毎にランダムに選んだ単語から100単語の文章を生成します。1epoch当たり2分くらい掛かります。ある程度学習が進むと、こんな感じの文章が生成されます。
![スクリーンショット 2020-06-15 17.00.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209705/156c31dd-487d-ef07-7adc-d2c6caa78e93.png)
　少しづつ文章がそれらしくなって行くのを見るのも、一興です。
#6.まとめ
　見よう見真似でコードを書いてみた感想は、**all python** で書かれた**シンプルなフレームワーク**なので、中身が分かりやすく**簡単にコードが書ける割りに自由度が高い**という良い印象を持ちました。これを期に、DeZero フレームワークの中身をしっかり勉強してみたいと思います。