1.はじめに
今年の4月、「ゼロから作るDeep Learning3 フレームワーク編」が発売されました。ゼロ作は1・2と読んでいて大変勉強になったので、今度はフレームワーク編に挑戦することにしました。
ということで最近本を購入したのですが、1ステップづつ勉強を始める前に、まずフレームワークの全体像をザッと知るために、とりあえずコードを書いてみようと思いました。
Githubにある DeZero のライブラリー と example を参考に、本をパラパラ見ながら自然言語処理の簡単なコードを google colab 上で書いてみたので、備忘録として残します。
なお、コードは Google Colab で作成し Github に上げてありますので、自分でやってみたい方は、この「リンク」をクリックし表示されたシートの先頭にある**「Colab on Web」**ボタンをクリックすると動かせます。
2.日本語データセットNekoクラス
日本語の自然言語処理をやろうとした時に、画像処理の場合のMNISTの様に簡単に使えるものがあると便利だなと思って似た様なものを作ってみました。
作成するデータセットクラスは、青空文庫の**「吾輩は猫である」をダウンロードし、余計な部分を削除してから janome で分かち書きを行い、辞書とcorpusを作成後、時系列データと次の正解データ**を作成するものとします。
 事前準備として、!pip install dezero でフレームワーク dezero をインストールし、!pip install janome で形態素分析ライブラリー janome をインストールします。
 データセットのクラス名は Neko とし、dezeroのお作法通り、Datasetクラスを継承し、def prepare() に処理内容を書き、その後に処理に必要な関数を書きます。
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:]  # 先頭の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 です。
 その後、関数を順次4つ呼び出して処理を行います。最後にお作法通り self.data (時系列データ)と self.label (次の正解データ)に corpus を1つズラしで代入します。
 変数 text, wakati, corpus, word_to_id, id_to_word のそれぞれに、self. を付けているのは、 Nekoクラスをインスタンス化したら、属性として呼び出せるようにするためです。
 def unzip()はダウンロードした zipファイルを解凍する関数。def preprocess() は解凍したファイルを読み込み、ルビや改行など余計な部分を削除したテキストを返す関数。def keitaiso() はテキストを形態素分析し分かち書きを返す関数。def process() は分かち書きから辞書とcorpusを作成する関数です。
では、実際に動かしてみましょう。
3.Nekoクラスを動かしてみる

 neko = Neko()で Nekoクラスをインスタンス化するとファイルをダウンロードし処理を開始します。janomeの分かち書き処理に少し時間が掛かるため、完了するまで数十秒程度掛ります。完了したら、早速使ってみましょう。

 neko.text でテキスト、neko.wakati で分かち書き、neko.corpus で corpus が表示できます。テキストはいわゆるベタ打ち、分かち書きは単語単位のリスト、corpus は分かち書きの単語の先頭から数字をふった(重複なし)ものです。ついでに、辞書も見ておきましょう。

 neko.waord_to_id[]は単語を数宇に変換する辞書、neko.id_to_word[] は数字を単語に変換する辞書です。学習データを見てみましょう。

 neko.data と neko.label は1つズレになっていることが分かります。最後に、data の長さと辞書に載っている単語数を見てみましょう。
 
 dataの長さ は 205,815個、辞書に載っている単語数 vocab_size は 13,616個です。
それでは、本体のコードを書きます。
4.本体コード
Nekoクラスを使って**「吾輩は猫である」の単語順**を学習し、それを元に文章を生成するコードを書いて行きます。
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 がこれで実現できるわけです。これはスマートですね。
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へ送ります。
# 学習ループ
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の整数倍か最後まで行ったら、逆伝播し重みを更新します。
 次に、1eopch毎に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分くらい掛かります。ある程度学習が進むと、こんな感じの文章が生成されます。

 少しづつ文章がそれらしくなって行くのを見るのも、一興です。
6.まとめ
見よう見真似でコードを書いてみた感想は、all python で書かれたシンプルなフレームワークなので、中身が分かりやすく簡単にコードが書ける割りに自由度が高いという良い印象を持ちました。これを期に、DeZero フレームワークの中身をしっかり勉強してみたいと思います。