Published Date : 2019年6月4日7:21


Deep Manzai Part 5







前回の簡単なあらすじ

前回 は台本のセリフの 一行文そのままにIDをつけて、 無理やりLSTMにぶっこむ。

できたものは、単にランダムに 文章が選ばれているであろう代物。


seq2seq Attention

よくよく調べてみたら、 会話の学習には、 seq2seqが使われ、
さらにAttentionという 進化版があるらしい。

いまさらかよ! と思われるほど、 自然言語処理界隈では
デファクトスタンダード (事実上の標準) になっていたらしいっす。

お恥ずかしい話で。 前紹介した本にも ちゃんと書いてありました。


ゼロから作るDeep Learning ❷ ―自然言語処理編

コードで理解する

秒速で5臆理解する。 そんなファンタジーはなく、
Attention Seq2Seqで対話モデルを実装してみた
この人のブログの、 (二年前のブログ!) コードを拝借して、 (サンキューです!)
自分のデータに対応できるよう、 ちょっと改良を加えて
まず、試して、 seq2seqとAttentionを 理解するための時間稼ぎを行います。


import datetime
import numpy as np
from chainer import Chain, Variable, cuda, optimizer, optimizers, serializers
import chainer.functions as F
import chainer.links as L
import MeCab
MeCabか。。。 取り敢えず時間がないので、 今回はMeCabをインストールしておく。

ちなみにChainerは 標準で使えるみたいっす。
ありがたいですね〜。

# MeCabのインストール
!apt-get install  mecab libmecab-dev mecab-ipadic mecab-ipadic-utf8
!pip install mecab-python3
終了DESU。

# GPUのセット
FLAG_GPU = False # GPUを使用するかどうか
if FLAG_GPU: # numpyかcuda.cupyか
    xp = cuda.cupy
    cuda.get_device(0).use()
else:
    xp = np



続いてこちら、 Colaboratory側でGPUの 設定を変更するため使わない。

代わりにこちらに変更。

print('GPU availability:', cuda.available)
print('cuDNN availablility:', cuda.cudnn_enabled)
# => GPU availability: True
# => cuDNN availablility: True
# GPUを使う場合はgpu_id=0にする。
gpu_id=0
#if gpu_id>=0:
  #model.to_gpu(gpu_id)
# そしてxpはnpに変更。

メインのクラス

カスタマイズしていきMASU!

ちなみに逐一MeCabを使って 分かち書きしているので、
分かち書き部分も 事前に終わらせているように 変更すれば、
スピードアップします。 これも次回に持ち越し。
# データ変換クラスの定義
class DataConverter:
  def __init__(self, batch_col_size):
      # クラスの初期化
      # :param batch_col_size: 学習時のミニバッチ単語数サイズ
      self.mecab = MeCab.Tagger() # 形態素解析器
      self.vocab = {"<eos>":0, "<unknown>": 1} # 単語辞書
      self.batch_col_size = batch_col_size

  def load(self, data):
      # 学習時に、教師データを読み込んでミニバッチサイズに対応したNumpy配列に変換する
      # :param data: 対話データ
      # 単語辞書の登録
      self.vocab = {"<eos>":0, "<unknown>": 1} # 単語辞書を初期化
      for d in data:
          sentences = [d[0][0], d[1][0]] # 入力文、返答文
          for sentence in sentences:
              sentence_words = self.sentence2words(sentence) # 文章を単語に分解する
              for word in sentence_words:
                  if word not in self.vocab:
                      self.vocab[word] = len(self.vocab)
      # 教師データのID化と整理
      queries, responses = [], []
      for d in data:
          query, response = d[0][0], d[1][0] #  エンコード文、デコード文
          queries.append(self.sentence2ids(sentence=query, train=True, sentence_type="query"))
          responses.append(self.sentence2ids(sentence=response, train=True, sentence_type="response"))
      # xp -> npに変更
      self.train_queries = np.vstack(queries)
      self.train_responses = np.vstack(responses)

  def sentence2words(self, sentence):
    # 文章を単語の配列にして返却する
    # :param sentence: 文章文字列
    sentence_words = []
    for m in self.mecab.parse(sentence).split("\n"): # 形態素解析で単語に分解する
        w = m.split("\t")[0].lower() # 単語
        if len(w) == 0 or w == "eos": # 不正文字、EOSは省略
            continue
        sentence_words.append(w)
    sentence_words.append("<eos>") # 最後にvocabに登録している<eos>を代入する
    return sentence_words

  def sentence2ids(self, sentence, train=True, sentence_type="query"):
    # 文章を単語IDのNumpy配列に変換して返却する
    # :param sentence: 文章文字列
    # :param train: 学習用かどうか
    # :sentence_type: 学習用でミニバッチ対応のためのサイズ補填方向をクエリー・レスポンスで変更するため"query"or"response"を指定 
    # :return: 単語IDのNumpy配列
    ids = [] # 単語IDに変換して格納する配列
    sentence_words = self.sentence2words(sentence) # 文章を単語に分解する
    for word in sentence_words:
        if word in self.vocab: # 単語辞書に存在する単語ならば、IDに変換する
            ids.append(self.vocab[word])
        else: # 単語辞書に存在しない単語ならば、<unknown>に変換する
            ids.append(self.vocab["<unknown>"])
    # 学習時は、ミニバッチ対応のため、単語数サイズを調整してNumpy変換する
    if train:
        if sentence_type == "query": # クエリーの場合は前方にミニバッチ単語数サイズになるまで-1を補填する
            while len(ids) > self.batch_col_size: # ミニバッチ単語サイズよりも大きければ、ミニバッチ単語サイズになるまで先頭から削る
                ids.pop(0)
            ids = np.array([-1]*(self.batch_col_size-len(ids))+ids, dtype="int32")
        elif sentence_type == "response": # レスポンスの場合は後方にミニバッチ単語数サイズになるまで-1を補填する
            while len(ids) > self.batch_col_size: # ミニバッチ単語サイズよりも大きければ、ミニバッチ単語サイズになるまで末尾から削る
                ids.pop()
            ids = np.array(ids+[-1]*(self.batch_col_size-len(ids)), dtype="int32")
    else: # 予測時は、そのままNumpy変換する
        ids = np.array([ids], dtype="int32")
    return ids

  def ids2words(self, ids):
    # 予測時に、単語IDのNumpy配列を単語に変換して返却する
    # :param ids: 単語IDのNumpy配列
    # :return: 単語の配列
    words = [] # 単語を格納する配列
    for i in ids: # 順番に単語IDを単語辞書から参照して単語に変換する
        words.append(list(self.vocab.keys())[list(self.vocab.values()).index(i)])
    return words

続いてモデルの作成部分。

さきほどのGPU対応に変えるだけ。
# モデルクラスの定義

# LSTMエンコーダークラス
class LSTMEncoder(Chain):
    def __init__(self, vocab_size, embed_size, hidden_size):
        # Encoderのインスタンス化
        # :param vocab_size: 使われる単語の種類数
        # :param embed_size: 単語をベクトル表現した際のサイズ
        # :param hidden_size: 隠れ層のサイズ
        super(LSTMEncoder, self).__init__(
            xe = L.EmbedID(vocab_size, embed_size, ignore_label=-1),
            eh = L.Linear(embed_size, 4 * hidden_size),
            hh = L.Linear(hidden_size, 4 * hidden_size)
        )
  
    def __call__(self, x, c, h):
        # Encoderの計算
        # :param x: one-hotな単語
        # :param c: 内部メモリ
        # :param h: 隠れ層
        # :return: 次の内部メモリ、次の隠れ層
        e = F.tanh(self.xe(x))
        return F.lstm(c, self.eh(e) + self.hh(h))
ここは変更なし。

NEXT!
# Attention Model + LSTMデコーダークラス
class AttLSTMDecoder(Chain):
    def __init__(self, vocab_size, embed_size, hidden_size):
        # Attention ModelのためのDecoderのインスタンス化
        # :param vocab_size: 語彙数
        # :param embed_size: 単語ベクトルのサイズ
        # :param hidden_size: 隠れ層のサイズ
        super(AttLSTMDecoder, self).__init__(
            ye = L.EmbedID(vocab_size, embed_size, ignore_label=-1), # 単語を単語ベクトルに変換する層
            eh = L.Linear(embed_size, 4 * hidden_size), # 単語ベクトルを隠れ層の4倍のサイズのベクトルに変換する層
            hh = L.Linear(hidden_size, 4 * hidden_size), # Decoderの中間ベクトルを隠れ層の4倍のサイズのベクトルに変換する層
            fh = L.Linear(hidden_size, 4 * hidden_size), # 順向きEncoderの中間ベクトルの加重平均を隠れ層の4倍のサイズのベクトルに変換する層
            bh = L.Linear(hidden_size, 4 * hidden_size), # 順向きEncoderの中間ベクトルの加重平均を隠れ層の4倍のサイズのベクトルに変換する層
            he = L.Linear(hidden_size, embed_size), # 隠れ層サイズのベクトルを単語ベクトルのサイズに変換する層
            ey = L.Linear(embed_size, vocab_size) # 単語ベクトルを語彙数サイズのベクトルに変換する層
        )
  
    def __call__(self, y, c, h, f, b):
        # Decoderの計算
        # :param y: Decoderに入力する単語
        # :param c: 内部メモリ
        # :param h: Decoderの中間ベクトル
        # :param f: Attention Modelで計算された順向きEncoderの加重平均
        # :param b: Attention Modelで計算された逆向きEncoderの加重平均
        # :return: 語彙数サイズのベクトル、更新された内部メモリ、更新された中間ベクトル
        e = F.tanh(self.ye(y)) # 単語を単語ベクトルに変換
        c, h = F.lstm(c, self.eh(e) + self.hh(h) + self.fh(f) + self.bh(b)) # 単語ベクトル、Decoderの中間ベクトル、順向きEncoderのAttention、逆向きEncoderのAttentionを使ってLSTM
        t = self.ey(F.tanh(self.he(h))) # LSTMから出力された中間ベクトルを語彙数サイズのベクトルに変換する
        return t, c, h
ここも変更なし。

NEXT!
# Attentionモデルクラス
class Attention(Chain):
    def __init__(self, hidden_size):
        # Attentionのインスタンス化
        # :param hidden_size: 隠れ層のサイズ
        super(Attention, self).__init__(
            fh = L.Linear(hidden_size, hidden_size), # 順向きのEncoderの中間ベクトルを隠れ層サイズのベクトルに変換する線形結合層
            bh = L.Linear(hidden_size, hidden_size), # 逆向きのEncoderの中間ベクトルを隠れ層サイズのベクトルに変換する線形結合層
            hh = L.Linear(hidden_size, hidden_size), # Decoderの中間ベクトルを隠れ層サイズのベクトルに変換する線形結合層
            hw = L.Linear(hidden_size, 1), # 隠れ層サイズのベクトルをスカラーに変換するための線形結合層
        )
        self.hidden_size = hidden_size # 隠れ層のサイズを記憶
  
    def __call__(self, fs, bs, h):
        # Attentionの計算
        # :param fs: 順向きのEncoderの中間ベクトルが記録されたリスト
        # :param bs: 逆向きのEncoderの中間ベクトルが記録されたリスト
        # :param h: Decoderで出力された中間ベクトル
        # :return: 順向きのEncoderの中間ベクトルの加重平均と逆向きのEncoderの中間ベクトルの加重平均
        batch_size = h.data.shape[0] # ミニバッチのサイズを記憶
        ws = [] # ウェイトを記録するためのリストの初期化
        sum_w = Variable(np.zeros((batch_size, 1), dtype='float32')) # ウェイトの合計値を計算するための値を初期化
        # Encoderの中間ベクトルとDecoderの中間ベクトルを使ってウェイトの計算
        for f, b in zip(fs, bs):
            w = F.tanh(self.fh(f)+self.bh(b)+self.hh(h)) # 順向きEncoderの中間ベクトル、逆向きEncoderの中間ベクトル、Decoderの中間ベクトルを使ってウェイトの計算
            w = F.exp(self.hw(w)) # softmax関数を使って正規化する
            ws.append(w) # 計算したウェイトを記録
            sum_w += w
        # 出力する加重平均ベクトルの初期化
        att_f = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))
        att_b = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))
        for f, b, w in zip(fs, bs, ws):
            w /= sum_w # ウェイトの和が1になるように正規化
            # ウェイト * Encoderの中間ベクトルを出力するベクトルに足していく
            att_f += F.reshape(F.batch_matmul(f, w), (batch_size, self.hidden_size))
            att_b += F.reshape(F.batch_matmul(b, w), (batch_size, self.hidden_size))
        return att_f, att_b
xpをnpに変更。

NEXT!
# Attention Sequence to Sequence Modelクラス
class AttSeq2Seq(Chain):
    def __init__(self, vocab_size, embed_size, hidden_size, batch_col_size):
        # Attention + Seq2Seqのインスタンス化
        # :param vocab_size: 語彙数のサイズ
        # :param embed_size: 単語ベクトルのサイズ
        # :param hidden_size: 隠れ層のサイズ
        super(AttSeq2Seq, self).__init__(
            f_encoder = LSTMEncoder(vocab_size, embed_size, hidden_size), # 順向きのEncoder
            b_encoder = LSTMEncoder(vocab_size, embed_size, hidden_size), # 逆向きのEncoder
            attention = Attention(hidden_size), # Attention Model
            decoder = AttLSTMDecoder(vocab_size, embed_size, hidden_size) # Decoder
        )
        self.vocab_size = vocab_size
        self.embed_size = embed_size
        self.hidden_size = hidden_size
        self.decode_max_size = batch_col_size # デコードはEOSが出力されれば終了する、出力されない場合の最大出力語彙数
        # 順向きのEncoderの中間ベクトル、逆向きのEncoderの中間ベクトルを保存するためのリストを初期化
        self.fs = []
        self.bs = []
  
    def encode(self, words, batch_size):
        # Encoderの計算
        # :param words: 入力で使用する単語記録されたリスト
        # :param batch_size: ミニバッチのサイズ
        # :return:
        # 内部メモリ、中間ベクトルの初期化
        c = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))
        h = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))
        # 順向きのEncoderの計算
        for w in words:
            c, h = self.f_encoder(w, c, h)
            self.fs.append(h) # 計算された中間ベクトルを記録
        # 内部メモリ、中間ベクトルの初期化
        c = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))
        h = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))        
        # 逆向きのEncoderの計算
        for w in reversed(words):
            c, h = self.b_encoder(w, c, h)
            self.bs.insert(0, h) # 計算された中間ベクトルを記録
        # 内部メモリ、中間ベクトルの初期化
        self.c = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))
        self.h = Variable(np.zeros((batch_size, self.hidden_size), dtype='float32'))        
  
    def decode(self, w):
        # Decoderの計算
        # :param w: Decoderで入力する単語
        # :return: 予測単語
        att_f, att_b = self.attention(self.fs, self.bs, self.h)
        t, self.c, self.h = self.decoder(w, self.c, self.h, att_f, att_b)
        return t
  
    def reset(self):
        # インスタンス変数を初期化する
        # Encoderの中間ベクトルを記録するリストの初期化
        self.fs = []
        self.bs = []
        # 勾配の初期化
        self.zerograds()
  
    def __call__(self, enc_words, dec_words=None, train=True):
        # 順伝播の計算を行う関数
        # :param enc_words: 発話文の単語を記録したリスト
        # :param dec_words: 応答文の単語を記録したリスト
        # :param train: 学習か予測か
        # :return: 計算した損失の合計 or 予測したデコード文字列
        enc_words = enc_words.T
        if train:
            dec_words = dec_words.T
        batch_size = len(enc_words[0]) # バッチサイズを記録
        self.reset() # model内に保存されている勾配をリセット
        enc_words = [Variable(np.array(row, dtype='int32')) for row in enc_words] # 発話リスト内の単語をVariable型に変更
        self.encode(enc_words, batch_size) # エンコードの計算
        t = Variable(np.array([0 for _ in range(batch_size)], dtype='int32')) # <eos>をデコーダーに読み込ませる
        loss = Variable(np.zeros((), dtype='float32')) # 損失の初期化
        ys = [] # デコーダーが生成する単語を記録するリスト
        # デコーダーの計算
        if train: # 学習の場合は損失を計算する
            for w in dec_words:
                y = self.decode(t) # 1単語ずつをデコードする
                t = Variable(np.array(w, dtype='int32')) # 正解単語をVariable型に変換
                loss += F.softmax_cross_entropy(y, t) # 正解単語と予測単語を照らし合わせて損失を計算
            return loss
        else: # 予測の場合はデコード文字列を生成する
            for i in range(self.decode_max_size):
                y = self.decode(t)
                y = np.argmax(y.data) # 確率で出力されたままなので、確率が高い予測単語を取得する
                ys.append(y)
                t = Variable(np.array([y], dtype='int32'))
                if y == 0: # EOSを出力したならばデコードを終了する
                    break
            return ys

xpをnpに変更。

NEXT!

いざ学習

dataの部分を修正します。

[ ['言葉'],['言葉] ]

このように、 対になる言葉が リストになっていて、
さらにそのリストが リストになり、 最終的にそれが リストになっています。

なんのこっちゃ。

# リストオブリストオブリストにするための処理。
re_script_lines=[[[script_lines[idx]],[script_lines[idx+1]]]for idx in range(0,len(script_lines)-1)]

# 確認。
re_script_lines[180:190]

# => [[['バカ ヤロー !'], ['知っ てる やろ お前 !']],....,[['いや 困っ て た ん か い 。'], ['もう ええ わ 。 どう も ありがとう ござい まし た 。']]]

ちなみに、構造としては、 1つ目の言葉に2つ目の言葉。

続いて、2つ目の言葉に3つ目と 並んでいきます。

つまり、一問一答形式ではないです。

続いていよいよ学習に 入るわけですが、
数が多いと一晩経っても 学習自体が始まらないため、
やむなく、100行程度を使用して 状態を見ることにしました。

# キリがいいところまで延長してDataに格納。
data = re_script_lines[0:190]

# 定数
EMBED_SIZE = 100
HIDDEN_SIZE = 100
BATCH_SIZE = 6 # ミニバッチ学習のバッチサイズ数
BATCH_COL_SIZE = 15
EPOCH_NUM = 100 # エポック数
N = len(data) # 教師データの数
 
# 教師データの読み込み
data_converter = DataConverter(batch_col_size=BATCH_COL_SIZE) # データコンバーター
data_converter.load(data) # 教師データ読み込み
vocab_size = len(data_converter.vocab) # 単語数
 
# モデルの宣言
model = AttSeq2Seq(vocab_size=vocab_size, embed_size=EMBED_SIZE, hidden_size=HIDDEN_SIZE, batch_col_size=BATCH_COL_SIZE)
# ネットワークファイルの読み込み
#network = "./att_seq2seq_network/*******************network"
#serializers.load_npz(network, model)
opt = optimizers.Adam()
opt.setup(model)
opt.add_hook(optimizer.GradientClipping(5))

# ここでGPUの設定。
if gpu_id >= 0:
  model.to_gpu(gpu_id)
model.reset()

最後にGPUの設定を変更しました。

学習スタートDESU!

# 学習開始
print("Train")
st = datetime.datetime.now()
for epoch in range(EPOCH_NUM):
    # ミニバッチ学習
    perm = np.random.permutation(N) # ランダムな整数列リストを取得
    total_loss = 0
    for i in range(0, N, BATCH_SIZE):
        enc_words = data_converter.train_queries[perm[i:i+BATCH_SIZE]]
        dec_words = data_converter.train_responses[perm[i:i+BATCH_SIZE]]
        model.reset()
        loss = model(enc_words=enc_words, dec_words=dec_words, train=True)
        loss.backward()
        loss.unchain_backward()
        total_loss += loss.data
        opt.update()
    #output_path = "./att_seq2seq_network/{}_{}.network".format(epoch+1, total_loss)
    #serializers.save_npz(output_path, model)
    if (epoch+1)%10 == 0:
        ed = datetime.datetime.now()
        print("epoch:\t{}\ttotal loss:\t{}\ttime:\t{}".format(epoch+1, total_loss, ed-st))
        st = datetime.datetime.now()
Train
epoch:	10	total loss:	1258.1063823699951	time:	0:04:30.949301
epoch:	20	total loss:	428.54298639297485	time:	0:04:31.070529
epoch:	30	total loss:	94.45460963249207	time:	0:04:29.476314
epoch:	40	total loss:	16.438214629888535	time:	0:04:30.523529
epoch:	50	total loss:	8.521537035703659	time:	0:04:31.335750
epoch:	60	total loss:	5.5262119844555855	time:	0:04:31.111734
epoch:	70	total loss:	4.15366418659687	time:	0:04:30.584443
epoch:	80	total loss:	3.4379720725119114	time:	0:04:32.047149
epoch:	90	total loss:	3.056693073362112	time:	0:04:34.333063
epoch:	100	total loss:	2.877363497391343	time:	0:04:31.113484

調子にのってエポック(繰り返し) を100に設定してみました。

約50分かかってますね。。。

損失関数は ソフトマックスクロスエントロピーです。

必殺技の名前みたいですよね。

「くらえ!ソフトマックスクロスエントロピー!」

単純に考えると誤差が 小さくなれば優秀です。はい。

応答せよ!
インプット文を使用して、 漫才っぽくチャレンジ。
print("\nPredict")
def predict(model, query):
    enc_query = data_converter.sentence2ids(query, train=False)
    dec_response = model(enc_words=enc_query, train=False)
    response = data_converter.ids2words(dec_response)
    # change
    print(query, "=<;", ''.join(response).replace('',''))
for i in range(10):
  predict(model, input('-> '))
会話が成立しないことだけの 面白みになっている。
Responsive image


こういうのは良くないですが、 関西弁に対して関西弁で 返してくるのはよしとしましょう。

次回へ続く

次回は上であげた課題を 解決していければなと思います。

こうして同じテーマの 無限ループは続く。。。

かもしれない。


See You Next Page!