Chainerを用いた対話システムの実装【seq2seq】

# はじめに
昨今，DNNs(Deep Neural Networks)の進歩が目覚ましくあらゆる分野で成功を収めています．
良く耳にするのは，画像分類や音声認識の分野ですが，対話システムも例外ではなくなりました．
Pythonのライブラリ環境が充実しつつある今，DNNsを用いた対話システムの構築について簡単に紹介したいと思います．

# 対話システムのためのDNNsモデル
対話システムを構築するためのDNNsのモデルは大きく分けて2つあります．

- 大量の応答候補に対するランキング学習 -> 入力に対して応答候補文をそのまま選択
- 発話と応答のペアから，Encoder-Decoderモデルを学習．-> 入力に対して単語単位で応答発話生成

本記事では，後者のEncoder-Decoderモデルについて扱います．
Chainerなどのライブラリが充実したおかげで，発話と応答のペアとなるデータさえあれば，誰でも実装が可能になりました．

## 実装環境
依存パッケージを以下にまとめます．
環境構築は，pip install or conda install コマンドで一発で出来ます．

- Chainer 1.16.0
- numpy 1.11.1


## seq2seq
本記事で述べるモデルは，seq2seq(Sequence to Sequence)と呼ばれるもので，入力用のエンコーダRNN(Reccurent Neural Network)と出力用のデコーダRNNの2種類のネットワークから構築されます(RNNの部分は一般的にはLSTMを用いて実装します)．
> 元論文: [Sequence to Sequence Learning with Neural Networks](http://papers.nips.cc/paper/5346-information-based-learning-by-agents-in-unbounded-state-spaces.pdf)

![Kobito.DW0UK9.png](https://qiita-image-store.s3.amazonaws.com/0/117645/d4fb865f-b5e5-d075-2eac-eeff9d255c8c.png "Kobito.DW0UK9.png")

対話システムに適用する場合，入力発話をEncoderに通し，それに対する応答がDecoderで単語ごとに学習されるような流れになります．


## 学習データ
今回は，対話破綻検出チャレンジ2のデータを用いて学習します．
対話破綻検出コーパスは，誰でも目的を問わず利用可能なコーパスなので安心して使うことができます．

対話破綻検出コーパス:
> URL: https://sites.google.com/site/dialoguebreakdowndetection2/downloads

コーパスの中身はjsonファイルで構築されています．
また，jsonファイルに格納されている対話を見やすく出力するスクリプトも同梱されています．
以下のように実行してみます．

```py:show_dial.py
$ python show_dial.py 1470622453.log.json 
```

実行結果:
> dialogue-id : 1470622453
speaker-id : DBD-01
group-id : 
S:こんにちは。熱中症に気をつけて。 O O O O O O O O O O O O O O O O O O O O O O O O O O O O O O 
U:はい。ありがとう。あなたも気を付けて。 
S:熱中症に気をつけないんですか？ T O T X X T X X X T O O T T T X X O X X X X X X T T O O T O 
... (中略)
S:運動は気分転換になってるんですね。気分は大丈夫ですね T T X O T O O O T T T O X O O X O O T O O T X T T T T T T T 

### 学習データの構築
上記の対話破綻検出コーパスを用いて，ユーザ発話(U)を学習用の入力発話に，その時のシステム発話を応答発話としてseq2seqを学習します．

![Kobito.mTPH4G.png](https://qiita-image-store.s3.amazonaws.com/0/117645/d53c856d-3ac6-e66e-27be-36090e7cbf6f.png "Kobito.mTPH4G.png")

上図のように，発話(Utterance)と応答(Response)が一対一となるファイルを作成し，それを学習データとして与えることになります．
下記スクリプトを使って，jsonファイルを学習データとなるテキストファイルに変換します．

```py:json2text.py
#!/usr/bin/env python                                                                                                                                                    
# -*- coding: utf-8 -*-                                                                                                                                                  

import sys
import os
import json


def loadingJson(dirpath, f):

    fpath = dirpath + '/' + f
    fj = open(fpath,'r')
    json_data = json.load(fj)
    fj.close()

    return json_data

def output(data, mod):

    for i in range(len(data['turns'])):
        if mod == "U" and data['turns'][i]['speaker'] == mod:
            print data['turns'][i]['utterance'].encode('utf-8')
        elif mod == "S" and data['turns'][i]['speaker'] == mod and i != 0:
            print data['turns'][i]['utterance'].encode('utf-8')
        else:
            continue


if __name__ == "__main__":

    argvs = sys.argv

    _usage = """--                                                                                                                                                       
Usage:                                                                                                                                                                   
    python json2text.py [json] [speaker]                                                                                                                                 
Args:                                                                                                                                                                    
    [json]: The argument is input directory that is contained files of json that is objective to convert to sql.                                                         
    [speaker]: The argument is "U" or "S" that is speaker in dialogue.                                                                                                   
""".rstrip()

    if len(argvs) < 3:
        print _usage
        sys.exit(0)

    # one file ver                                                                                                                                                       
    '''                                                                                                                                                                  
    fj = open(argvs[1],'r')                                                                                                                                              
    json_data = json.load(fj)                                                                                                                                            
    fj.close()      
    
    output(json_data, mod)                                                                                                                                                                                                                                                                                                        
    '''

    # more than two files ver                                                                                                                                            
    branch = os.walk(argvs[1])
    mod = argvs[2]

    for dirpath, dirs, files in branch:
        for f in files:
            json_data = loadingJson(dirpath, f)
            output(json_data, mod)
```

実行は下記のように行います．

```py:json2text.py
$ python json2text.py [json] [speaker]
```

- [json]: 入力とするjsonファイルが格納されているディレクトリ (1つのjsonファイルを入力とするときは，L47〜53のコメントアウトした部分を有効にし，代わりにL56以下を無効にします)
- [speaker]: Utteranceの学習データを作成する場合は"U"，Responseの学習データを作成する場合は"S"と入力

この処理によって学習データ(Utterance, Response)の一歩手前まで完成しました．
あとは，形態素解析して分かち書きします．

```
$ mecab -Owakati Utterance.txt > Utterance_wakati.txt
```



## 対話モデルの学習
ここまでの処理で学習データ(Utterance, Response)ができました．
続いてモデルの学習をしていきます．

```py:learning.py
#!/usr/bin/env python                                                                                                                                                    
# -*- coding: utf-8 -*-                                                                                                                                                  

import sys
import numpy as np
import chainer
from chainer import cuda, Function, gradient_check, Variable, optimizers, serializers, utils
from chainer import Link, Chain, ChainList
import chainer.functions as F
import chainer.links as L


class seq2seq(chainer.Chain):
    def __init__(self, jv, ev, k, jvocab, evocab):
        super(seq2seq, self).__init__(
            embedx = L.EmbedID(jv, k),
            embedy = L.EmbedID(ev, k),
            H = L.LSTM(k, k),
            W = L.Linear(k, ev),
            )

    def __call__(self, jline, eline, jvocab, evocab):
        for i in range(len(jline)):
            wid = jvocab[jline[i]]
            x_k = self.embedx(Variable(np.array([wid], dtype=np.int32)))
            h = self.H(x_k)
        x_k = self.embedx(Variable(np.array([jvocab['<eos>']], dtype=np.int32)))
        tx = Variable(np.array([evocab[eline[0]]], dtype=np.int32))
        h = self.H(x_k)
        accum_loss = F.softmax_cross_entropy(self.W(h), tx)
        for i in range(len(eline)):
            wid = evocab[eline[i]]
            x_k = self.embedy(Variable(np.array([wid], dtype=np.int32)))
            next_wid = evocab['<eos>'] if (i == len(eline) - 1) else evocab[eline[i+1]]
            tx = Variable(np.array([next_wid], dtype=np.int32))
            h = self.H(x_k)
            loss = F.softmax_cross_entropy(self.W(h), tx)
            accum_loss += loss

        return accum_loss

def main(epochs, urr_file, res_file, out_path):

    jvocab = {}
    jlines = open(utt_file).read().split('\n')
    for i in range(len(jlines)):
        lt = jlines[i].split()
        for w in lt:
            if w not in jvocab:
                jvocab[w] = len(jvocab)

    jvocab['<eos>'] = len(jvocab)
    jv = len(jvocab)

    evocab = {}
    elines = open(res_file).read().split('\n')
    for i in range(len(elines)):
        lt = elines[i].split()
        for w in lt:
            if w not in evocab:
		evocab[w] = len(evocab)
	    ev = len(evocab)

    	demb = 100
    	model = seq2seq(jv, ev, demb, jvocab, evocab)
    	optimizer = optimizers.Adam()
    	optimizer.setup(model)

    	for epoch in range(epochs):
       		for i in range(len(jlines)-1):
           	jln = jlines[i].split()
            	jlnr = jln[::-1]
            	eln = elines[i].split()
            	model.H.reset_state()
            	model.zerograds()
            	loss = model(jlnr, eln, jvocab, evocab)
            	loss.backward()
            	loss.unchain_backward()
            	optimizer.update()
            	print i, " finished"		

        	outfile = out_path + "/seq2seq-" + str(epoch) + ".model"
        	serializers.save_npz(outfile, model)



if __name__ == "__main__":

    argvs = sys.argv

    _usage = """--                                                                                                                                                       
Usage:                                                                                                                                                                   
    python learning.py [epoch] [utteranceDB] [responseDB] [save_link]                                                                                                    
Args:                                                                                                                                                                    
    [epoch]: The argument is the number of max epochs to train models.                                                                                                   
    [utteranceDB]: The argument is input file to train model that is to convert as pre-utterance.                                                                        
    [responseDB]: The argument is input file to train model that is to convert as response to utterance.                                                                 
    [save_link]: The argument is output directory to save trained models.                                                                                                
""".rstrip()

    if len(argvs) < 5:
        print _usage
        sys.exit(0)


    epochs = int(argvs[1])
    utt_file = argvs[2]
    res_file = argvs[3]
    out_path = argvs[4]

    main(epochs, utt_file, res_file, out_path)
```

実行は以下のようにします．

```py:learning.py
$ python learning.py [epoch] [utternceDB] [responseDB] [savelink]
```

- [epoch]: 学習回数
- [utteranceDB]: 学習データ(Utterance)
- [responseDB]: 学習データ(Response)
- [savelink]: 学習したモデルを保存するためのディレクトリ

## Let's Conversation!
やっと学習が終わりました．
さて，会話してみましょう！

```py:generating.py
#!/usr/bin/env python                                                                                                                                                    
# -*- coding: utf-8 -*-                                                                                                                                                  

import sys
import numpy as np
import mecab as mcb
import chainer
from chainer import cuda, Function, gradient_check, Variable, optimizers, serializers, utils
from chainer import Link, Chain, ChainList
import chainer.functions as F
import chainer.links as L



class seq2seq(chainer.Chain):
    def __init__(self, jv, ev, k, jvocab, evocab):
        super(seq2seq, self).__init__(
            embedx = L.EmbedID(jv, k),
            embedy = L.EmbedID(ev, k),
            H = L.LSTM(k, k),
            W = L.Linear(k, ev),
            )

    def __call__(self, jline, eline, jvocab, evocab):
        for i in range(len(jline)):
            wid = jvocab[jline[i]]
            x_k = self.embedx(Variable(np.array([wid], dtype=np.int32)))
            h = self.H(x_k)
        x_k = self.embedx(Variable(np.array([jvocab['<eos>']], dtype=np.int32)))
        tx = Variable(np.array([evocab[eline[0]]], dtype=np.int32))
        h = self.H(x_k)
        accum_loss = F.softmax_cross_entropy(self.W(h), tx)
        for i in range(1,len(eline)):
            wid = evocab[eline[i]]
            x_k = self.embedy(Variable(np.array([wid], dtype=np.int32)))
            next_wid = evocab['<eos>'] if (i == len(eline) - 1) else evocab[eline[i+1]]
            tx = Variable(np.array([next_wid], dtype=np.int32))
            h = self.H(x_k)
            loss = F.softmax_cross_entropy(self.W(h), tx)
            accum_loss = loss if accum_loss is None else accum_loss + loss
            
        return accum_loss
        
def mt(model, jline, id2wd, jvocab, evocab):
    for i in range(len(jline)):
        wid = jvocab[jline[i]]
        x_k = model.embedx(Variable(np.array([wid], dtype=np.int32), volatile='on'))
        h = model.H(x_k)
    x_k = model.embedx(Variable(np.array([jvocab['<eos>']], dtype=np.int32), volatile='on'))
    h = model.H(x_k)
    wid = np.argmax(F.softmax(model.W(h)).data[0])
    if wid in id2wd:
        print id2wd[wid],
    else:
        print wid,
    loop = 0
    while (wid != evocab['<eos>']) and (loop <= 30):
        x_k = model.embedy(Variable(np.array([wid], dtype=np.int32), volatile='on'))
        h = model.H(x_k)
        wid = np.argmax(F.softmax(model.W(h)).data[0])
        if wid in id2wd:
            print id2wd[wid],
        else:
            print wid,
        loop += 1
    print

def constructVocabs(corpus, mod):

    vocab = {}
    id2wd = {}
    lines = open(corpus).read().split('\n')
    for i in range(len(lines)):
        lt = lines[i].split()
        for w in lt:
            if w not in vocab:
                if mod == "U":
                    vocab[w] = len(vocab)
                elif mod == "R":
                    id2wd[len(vocab)] = w
                    vocab[w] = len(vocab)

    if mod == "U":
        vocab['<eos>'] = len(vocab)
        v = len(vocab)
        return vocab, v
    elif mod == "R":
        id2wd[len(vocab)] = '<eos>'
        vocab['<eos>'] = len(vocab)
        v = len(vocab)
        return vocab, v, id2wd
         
def main(mpath, utt_file, res_file):

    jvocab, jv = constructVocabs(utt_file, mod="U")
    evocab, ev, id2wd = constructVocabs(res_file, mod="R")

    demb = 100
    model = seq2seq(jv, ev, demb, jvocab, evocab)
    serializers.load_npz(mpath, model)

    while True:
        utterance = raw_input()
        if utterance == "exit":
            print "Bye!!"
            sys.exit(0)
                                                                                                                                   
        jln = mcb.construct_BoW(utterance)
        jlnr = jln[::-1]
        mt(model, jlnr, id2wd, jvocab, evocab)

                                        
if __name__ == "__main__":

    argvs = sys.argv

    _usage = """--                                                                                                                                                       
Usage:                                                                                                                                                                   
    python generating.py [model] [uttranceDB] [responseDB]                                                                                                               
Args:                                                                                                                                                                    
    [model]: The argument is seq2seq model to be trained using dialog corpus.                                                                                            
    [utteranceDB]: The argument is utterance corpus to gain the distributed representation of words.                                                                     
    [responseDB]: The argument is response corpus to gain the distributed representation of words.                                                                       
""".rstrip()

    if len(argvs) < 4:
        print _usage
        sys.exit(0)


    model = argvs[1]
    utt_file = argvs[2]
    res_file = argvs[3]

    main(model, utt_file, res_file)                                                                                                                 
```

```py:generating.py
$ python generating.py [model]
```

- [model]: 学習したモデル(seq2seq-**.model)
- [utteranceDB]: 学習データ(Utterance)
- [responseDB]: 学習データ(Response)

実際に対話してみた結果がこちら！

Epoch=1:
> U: こんにちは
S: 私 は 、 最近 に は 、 最近 に 行っ て い ます 。 <eos>
U: 性別は何？
S: そうですね 、 どちら に 行っ て い ます 。 <eos>
U: 恋人はいるの？
S: そうですね 、 どちら に 行っ て い ます 。 <eos>

Epoch=10:
> U: こんにちは
S: こんにちは <eos>
U: 性別は何？
S: 占い です か ！ 私 は 苦手 です 。 。 。 冬 服 は 重い し 高い 。 <eos>
U: 恋人はいるの？
S: 最近 の ある こと は あり ます か ？ <eos>

Epoch=30:
> U: こんにちは
S: こんにちは 、 よろしく お願い し ます <eos>
U: 性別は何？
S: 逆子 で 性別 が わかっ て ない かも です <eos>
U: 恋人はいるの？
S: 恋人 を 求める かも しれ ない <eos>

Epochが1のときは全く学習ができていない感じですが，Epochが上がるごとにどんどん答え方が良い感じになっています！
ただし，今回はclosed test(コーパス中の発話を入力)としたので，実際の対話ではもっと酷い有様になります．
改善には学習データ数を3桁くらい増やす必要がありそうです．

# まとめ
今回はseq2seqモデルを使って単語単位で発話生成が可能な対話システムを実装しました．
実装にあたり大量の学習データを用意する必要があることが課題になりますが，逆に言えばデータさえあればそれっぽい対話ができるシステムができます．
使うデータの分布によって，答え方も大きく違ってきそうです．

面白そうだな〜と感じたら，ぜひ試してみてください！
Let's Conversation!