Edited at
ChainerDay 19

PyCon 2016で発表したChat Botをコードベースから解説(Chainerを利用した雑談応答編)

More than 1 year has passed since last update.


WHY

深層学習について興味がある方が多いと思うので対話における深層学習の実装について記述します。

雑談応答がChainerを使用しているため、その部分にフォーカスして説明します。ただしバージョンが古いので注意が必要です。

動作確認しているバージョンは1.5.1です

間違いがある部分があるかもしれません。深く理解したい部分があったので一部Chainerのコードを追っています。間違いがあれば大変お手数ですがご指摘いただけると幸いです。


  • PyCon 2016で発表した内容はどちらかというとコンセプトや概要ベースを伝えて頂けなので実際に実装しているコードの説明がないので、自身の振り返りという意味でもあった方が良い


  • そこでコードの説明を加えることでもっと理解して使ってもらえる人が増えて欲しいと思い、この記事を書きました。(できればgithubのスターが増えると嬉しい)


Screen Shot 2016-12-26 at 8.04.52 AM.png

Docker Hub

https://hub.docker.com/r/masayaresearch/dialogue/

github

https://github.com/SnowMasaya/Chainer-Slack-Twitter-Dialogue

他にも質問応答、話題分類、データ取得の並列化など多岐に渡っているのでその部分も要望があれば書きます。


WHAT


雑談応答

分類されたデータに対して学習を行っています。深層学習の中でもアテンションモデルを使用しています。

アテンションモデルとは

ニューラルネットワークの機械翻訳のタスクにおいてシーケンス to シーケンスのモデルでは長文の入力において一つのベクトルに集約されると最初の単語の重要性が微分の集積によって薄れてくる問題がありました。英語の場合は特に最初の単語の重要性が増してきます。

それを解決するため、従来では逆方向の入力を入れて翻訳精度を上げていました。しかし日本語や中国語の場合は逆に最後の単語が重要になるため本質的な解決になりません。

そこで入力のエンコードとデコードを分けて行わずにデコードと対応している隠れ層とエンコードの入力を加重平均して各デコードごとの出力を予測するモデルとして提案されたのがアテンションモデルです。もともとは画像の分野で成果を出していましたが、現在は機械翻訳や文章要約のタスクで成果を出しています。

イメージ

image.png

image.png

"も"を予測するには”僕”と入力("私 は エンジニア だ")が得られた時の事後確率になります。

事後確率は一つ前の単語(僕)と隠れ層の状態とコンテキストベクトル("私 は エンジニア だ")のスコアになります。コンテキストベクトルは今は無視してください。あとで解説をします。

関数gはソフトマックス関数が一般的です

Screen Shot 2016-12-26 at 9.38.00 AM.png

上図のように事前の出力を考慮して、現在の状態とコンテキストから予測する場合に使用する数式は下記です。

p(y_i|y_1,...y_{i_1}, \vec{x}) = g(y_{i-1}, s_i, c_i)

ここで隠れ層の時刻tの状態は下記のようにできる。("も"を予測するための状態)

これは一つ前の単語"僕"と一つ前の状態と一つ前の"私 は エンジニア だ"のコンテキストベクトルによって決まります。

関数fはシグモイドが一般的

s_i=f(s_{i-1}, y_{i-1},c_i)

コンテキストベクトルはエンコーダー部分("私 は エンジニア だ”)の隠れ層と重み$a$の総和で決まります。

c_{i} = \sum^{T_x}_{j=1}\alpha_{ij}h_{j}

では先ほど定義した重みをどのように求めるかですが、eという隠れ層hと出力側の一つ前の状態s("は”の場合は"僕")から得られる重みをスコアにしています。このような形はエンコーダー部分のhが特殊な形になるためです。この点は後述します。

eというスコアは確率のため小さい値になります。それをexp関数によって大きい値にして入力部全てで割り算をして入力と出力のペアにマッチした重みを算出しています。

\alpha_{ij} = \frac{exp(e_{ij})}{\sum_{k=1}^{T_x}exp(e_{ik})} \\

e_{ij} = a(s_{i-1}, h_j)

では隠れ層hはどのような点が特殊なのでしょうか。

実はフォワードとバックワードを合わせている点が通常のシーケンス to シーケンスとは異なります。

下記のようにフォワードとバックワードを定義して、それをコンカチして表します。これがエンコード入力"私 は エンジニア だ"の隠れ層になります。

(\vec{h_1},...\vec{h_{T_x}})\\

(\overleftarrow{h_1},...\overleftarrow{h_{T_x}})\\
h_j = [\vec{h_j^T};\overleftarrow{h_j^T}]^T

では実際にコードベースでこの数式をどのように実現しているか追っていきましょう。


  • src_embed.py


    • 言語のデータをニューラルネットの空間に移す部分です。



  • attention_encoder.py


    • 入力側言語のニューラルネットの空間に移された情報を伝搬する部分です。(対話で言うユーザーの発話部分にあたります)



  • attention.py


    • コンテキスト情報を作成する部分




  • attention_decoder.py


    • 出力言語のニューラルネット部分になります。コンテキスト情報、ターゲット言語の出力、隠れ層の伝搬まで行う。




  • attention_dialogue.py


    • モデルの読み込み

    • モデルの保存

    • 重みの初期化

    • 重みの埋め込み

    • エンコード処理

    • デコード処理



上記の5つで構成されます。


HOW


src_embed.py

Screen Shot 2016-12-26 at 9.38.00 AM.png

入力言語の情報を埋め込む部分から解説します。

入力言語の語彙とニューラルネットの埋め込み層の数を設定します。

入力言語の語彙は対話の場合はユーザーの発話になります。

    def __init__(self, vocab_size, embed_size):

super(SrcEmbed, self).__init__(
weight_xi=links.EmbedID(vocab_size, embed_size),
)

具体的な処理の中身になります。

W (~chainer.Variable)はchainer.Variableの埋め込み行列になります。

平均0、分散1.0の正規分布から生成される初期重みを使用します。

    def __init__(self, in_size, out_size, initialW=None, ignore_label=None):

super(EmbedID, self).__init__(W=(in_size, out_size))
if initialW is None:
initialW = initializers.Normal(1.0)
initializers.init_weight(self.W.data, initialW)
self.ignore_label = ignore_label

具体的に正規分布からデータを生成している部分になります。

xpの部分はgpuを使用する場合なのでnumpy.random.normalでなくxp.random.normalを使用しています。

参考

https://docs.scipy.org/doc/numpy/reference/generated/numpy.random.normal.html

class Normal(initializer.Initializer):

def __init__(self, scale=0.05, dtype=None):
self.scale = scale
super(Normal, self).__init__(dtype)

def __call__(self, array):
xp = cuda.get_array_module(array)
array[...] = xp.random.normal(
loc=0.0, scale=self.scale, size=array.shape)

ここで返却する初期の重みは下記で設定しています。

initializerのデータはnumpy.ndarrayのデータかクラスもしくはcupy.ndarrayのクラスで設定されます。

def init_weight(weights, initializer, scale=1.0):

if initializer is None:
initializer = HeNormal(1 / numpy.sqrt(2))
elif numpy.isscalar(initializer):
initializer = Constant(initializer)
elif isinstance(initializer, numpy.ndarray):
initializer = Constant(initializer)

assert callable(initializer)
initializer(weights)
weights *= scale

initializerNone以外の場合にgpu形式のarrayか通常のarrayかを返却しています。


class Constant(initializer.Initializer):

def __init__(self, fill_value, dtype=None):
self.fill_value = fill_value
super(Constant, self).__init__(dtype)

def __call__(self, array):
if self.dtype is not None:
assert array.dtype == self.dtype
xp = cuda.get_array_module(array)
array[...] = xp.asarray(self.fill_value)

具体的に判定して返却している部分は下記になります。

def get_array_module(*args):

if available:
return cupy.get_array_module(*args)
else:
return numpy

__call__関数によってsrc_embedを呼び出して入力言語をニューラルネットの空間に埋め込んでいます。

functions.tanhで双極関数を用いて微分可能な空間に写像しています。微分可能な空間であれば誤差逆伝搬により学習が可能になります。

    def __call__(self, source):

return functions.tanh(self.weight_xi(source))


attention_encoder.py

Screen Shot 2016-12-26 at 9.38.00 AM.png

先ほどのニューラルネットの空間に写像された入力層を隠れ層に渡しています。

なぜ4倍か

入力ゲート

忘却ゲート

出力ゲート

以前の入力を考慮するゲート

上記の4つを考慮しているためです。

なぜそれが必要かは他の資料でも言及されているので詳細は述べませんが、この工夫によって過学習を防いでいます。

    def __init__(self, embed_size, hidden_size):

super(AttentionEncoder, self).__init__(
source_to_hidden=links.Linear(embed_size, 4 * hidden_size),
hidden_to_hidden=links.Linear(hidden_size, 4 * hidden_size),
)

具体的なlinks.Linerの処理になります。


  • 重みの初期化

  • 重みのパラメータ付与

  • バイアスの初期化

  • バイアスのパラメータ付与

    def __init__(self, in_size, out_size, wscale=1, bias=0, nobias=False,

initialW=None, initial_bias=None):
super(Linear, self).__init__()

self.initialW = initialW
self.wscale = wscale

self.out_size = out_size
self._W_initializer = initializers._get_initializer(initialW, math.sqrt(wscale))

if in_size is None:
self.add_uninitialized_param('W')
else:
self._initialize_params(in_size)

if nobias:
self.b = None
else:
if initial_bias is None:
initial_bias = bias
bias_initializer = initializers._get_initializer(initial_bias)
self.add_param('b', out_size, initializer=bias_initializer)

def _initialize_params(self, in_size):
self.add_param('W', (self.out_size, in_size), initializer=self._W_initializer)

具体的な初期化処理になります。

scaleがデファルトで1なのでそれをかけてArrayを作成します。

先ほど出てきたConstantで初期値を固定値で初期化してscaleをかけています。

class _ScaledInitializer(initializer.Initializer):

def __init__(self, initializer, scale=1.0):
self.initializer = initializer
self.scale = scale
dtype = getattr(initializer, 'dtype', None)
super(Identity, self).__init__(dtype)

def __call__(self, array):
self.initializer(array)
array *= self.scale

def _get_initializer(initializer, scale=1.0):
if initializer is None:
return HeNormal(scale / numpy.sqrt(2))
if numpy.isscalar(initializer):
return Constant(initializer * scale)
if isinstance(initializer, numpy.ndarray):
return Constant(initializer * scale)

assert callable(initializer)
if scale == 1.0:
return initializer
return _ScaledInitializer(initializer, scale)

現在の状態と前回の隠れ層の値、入力層の値を渡しています。


def __call__(self, source, current, hidden):
return functions.lstm(current, self.source_to_hidden(source) + self.hidden_to_hidden(hidden))

上記のlstmでforwardの処理の際に呼ばれている処理が下記になります。

ファイルはchainer/functions/activation/lstm.pyになります。

入力をlstmの4つのゲートに分けています。

len(x):行の長さの取得

x.shape[1]:列の長さの取得

x.shape[2:]:3次元以上のデータの場合に使用

    def _extract_gates(x):

r = x.reshape((len(x), x.shape[1] // 4, 4) + x.shape[2:])
return [r[:, :, i] for i in six.moves.range(4)]

cpuの処理


  • 入力と状態を取得

  • lstmに基づいて4つの情報を取得

  • lstmの値は論文に準拠しているため、アテンション以外は入力がはハイパボリックタンジェントで実現可能

  • 次の状態をランダムに初期化

  • 入力とアテンションの積と忘却と過去の状態の積の話によってバッチサイズ分、次の状態を付与

  • 隠れ層の値は出力と次の状態のハイパボリックタンジェントで与える

gpuの処理は同一ただし、C++を使用するため下記の定義を使用して読んでいる。python上で定義しているlstmで同一だがC++で処理するために記述されている。

_preamble = '''

template <typename T> __device__ T sigmoid(T x) {
const T half = 0.5;
return tanh(x * half) * half + half;
}
template <typename T> __device__ T grad_sigmoid(T y) { return y * (1 - y); }
template <typename T> __device__ T grad_tanh(T y) { return 1 - y * y; }
#define COMMON_ROUTINE \
T aa = tanh(a); \
T ai = sigmoid(i_); \
T af = sigmoid(f); \
T ao = sigmoid(o);
'''

    def forward(self, inputs):

c_prev, x = inputs
a, i, f, o = _extract_gates(x)
batch = len(x)

if isinstance(x, numpy.ndarray):
self.a = numpy.tanh(a)
self.i = _sigmoid(i)
self.f = _sigmoid(f)
self.o = _sigmoid(o)

c_next = numpy.empty_like(c_prev)
c_next[:batch] = self.a * self.i + self.f * c_prev[:batch]
h = self.o * numpy.tanh(c_next[:batch])
else:
c_next = cuda.cupy.empty_like(c_prev)
h = cuda.cupy.empty_like(c_next[:batch])
cuda.elementwise(
'T c_prev, T a, T i_, T f, T o', 'T c, T h',
'''
COMMON_ROUTINE;
c = aa * ai + af * c_prev;
h = ao * tanh(c);
'''
,
'lstm_fwd', preamble=_preamble)(
c_prev[:batch], a, i, f, o, c_next[:batch], h)

c_next[batch:] = c_prev[batch:]
self.c = c_next[:batch]
return c_next, h

gpuの処理は下記になります。

cudaの中身を呼ぶための処理が下記。

cupyを使用している。

cupyについて

http://docs.chainer.org/en/stable/cupy-reference/overview.html

下記でカーネル関数を作成し、cudaのメモリにキャッシュして結果をcudaのデバイスと連携します。

gpuのメモリ空間で計算した値を連携しないといけない理由は下記をご覧ください。

http://www.nvidia.com/docs/io/116711/sc11-cuda-c-basics.pdf

@memoize(for_each_device=True)

def elementwise(in_params, out_params, operation, name, **kwargs):
check_cuda_available()
return cupy.ElementwiseKernel(
in_params, out_params, operation, name, **kwargs)

backwordの際は下記の処理になります。

chainerはここら辺の処理を隠してくれるので助かります。

forwardの処理と同じですが、違いは入力だけでなく勾配の出力も使っていることになります。

gc_prev[:batch]で隠れ層と出力層の積に勾配を足してバッチサイズ分更新しています。

_grad_tanh_grad_sigmoidで勾配を計算し更新している。

            co = numpy.tanh(self.c)

gc_prev = numpy.empty_like(c_prev)
# multiply f later
gc_prev[:batch] = gh * self.o * _grad_tanh(co) + gc_update
gc = gc_prev[:batch]
ga[:] = gc * self.i * _grad_tanh(self.a)
gi[:] = gc * self.a * _grad_sigmoid(self.i)
gf[:] = gc * c_prev[:batch] * _grad_sigmoid(self.f)
go[:] = gh * co * _grad_sigmoid(self.o)
gc_prev[:batch] *= self.f # multiply f here
gc_prev[batch:] = gc_rest

gpuの処理部分になります。

cpuの処置と同一ですがC++を通すためにcuda.elementwiseを使用して計算しています。

            a, i, f, o = _extract_gates(x)

gc_prev = xp.empty_like(c_prev)
cuda.elementwise(
'T c_prev, T c, T gc, T gh, T a, T i_, T f, T o',
'T gc_prev, T ga, T gi, T gf, T go',
'''
COMMON_ROUTINE;
T co = tanh(c);
T temp = gh * ao * grad_tanh(co) + gc;
ga = temp * ai * grad_tanh(aa);
gi = temp * aa * grad_sigmoid(ai);
gf = temp * c_prev * grad_sigmoid(af);
go = gh * co * grad_sigmoid(ao);
gc_prev = temp * af;
'''
,
'lstm_bwd', preamble=_preamble)(
c_prev[:batch], self.c, gc_update, gh, a, i, f, o,
gc_prev[:batch], ga, gi, gf, go)
gc_prev[batch:] = gc_rest


attention.py

Screen Shot 2016-12-26 at 9.38.00 AM.png

コンテキスト情報を保持する部分になります。



  • annotion_weightがフォワードの部分の重み


  • back_weightがバックワード部分の重み、


  • pwが現在の層の重み


  • weight_exponentialがニューラルネットでexp関数を処理できるようにするための設定

    def __init__(self, hidden_size):

super(Attention, self).__init__(
annotion_weight=links.Linear(hidden_size, hidden_size),
back_weight=links.Linear(hidden_size, hidden_size),
pw=links.Linear(hidden_size, hidden_size),
weight_exponential=links.Linear(hidden_size, 1),
)
self.hidden_size = hidden_size

annotion_listがフォワードの単語のリスト

back_word_listがバックワードの単語のリスト

pが現在の層の重み


def __call__(self, annotion_list, back_word_list, p):

バッチ処理用の初期化


batch_size = p.data.shape[0]
exponential_list = []
sum_exponential = XP.fzeros((batch_size, 1))

forwardの単語リストとback_wordの単語リスト、現在の層の状態を総合した重みを作成

下記に相当

e_{ij} = a(s_{i-1}, h_j)

そこで得られた値をexp関数で処理できるようにして、各値をリスト化

合計値も算出します

\alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{k=1}^{T_x}\exp(e_{ik})} \\

両方向から処理するため、前方向のアノテーションリストと後ろ方向からのバックワードリストを取得して現在の重みを含めて重み計算をします。

exp関数の重みリストを作成。

exp関数の総和を計算


for annotion, back_word in zip(annotion_list, back_word_list):
weight = functions.tanh(self.annotion_weight(annotion) + self.back_weight(back_word) + self.pw(p))
exponential = functions.exp(self.weight_exponential(weight))
exponential_list.append(exponential)
sum_exponential += exponential

初期化を行い、フォワード、バックワードの重みを計算しておき、その重みをフォワード、バックワードで行列計算した値をバッチサイズ分用意して返しています。functions.batch_matmulで行列計算をします。

aが左行列

bが右行列

transaがあるときは左の行列の転置を行う。

transbがあるときは右の行列の転置を行う

def batch_matmul(a, b, transa=False, transb=False):

return BatchMatMul(transa=transa, transb=transb)(a, b)

実際の行列計算の中身

- 行列を計算できる形に変換

では行列を要素ごとに計算できるように変換している



a = a.reshape(a.shape[:2] + (-1,))

下記のような行列があった時に

array([[1, 2, 3],

[4, 5, 6],
[3, 4, 5]])

それを下記のように変換している。

array([[[1],

[2],
[3]],

[[4],
[5],
[6]],

[[3],
[4],
[5]]])


  • もし転置が必要であれば処理を行う。

  • 答えとして返す行列をランダムに初期化

  • numpyであれば行列の要素ごとに計算しgpu用のcupyであればmatmulを使用して行列計算をする。matmulはスカラー計算を許可しておらず、行列をスタックに積んで処理を行う。

def _batch_matmul(a, b, transa=False, transb=False, transout=False):

a = a.reshape(a.shape[:2] + (-1,))
b = b.reshape(b.shape[:2] + (-1,))
trans_axis = (0, 2, 1)
if transout:
transa, transb = not transb, not transa
a, b = b, a
if transa:
a = a.transpose(trans_axis)
if transb:
b = b.transpose(trans_axis)
xp = cuda.get_array_module(a)
if xp is numpy:
ret = numpy.empty(a.shape[:2] + b.shape[2:], dtype=a.dtype)
for i in six.moves.range(len(a)):
ret[i] = numpy.dot(a[i], b[i])
return ret
return xp.matmul(a, b)

バッチサイズと行列のサイズ分、ゼロ行列で初期化をして、annotionback_wordで計算した総和を返します。


ZEROS = XP.fzeros((batch_size, self.hidden_size))
annotion_value = ZEROS
back_word_value = ZEROS
# Calculate the Convolution Value each annotion and back word
for annotion, back_word, exponential in zip(annotion_list, back_word_list, exponential_list):
exponential /= sum_exponential
annotion_value += functions.reshape(functions.batch_matmul(annotion, exponential), (batch_size, self.hidden_size))
back_word_value += functions.reshape(functions.batch_matmul(back_word, exponential), (batch_size, self.hidden_size))
return annotion_value, back_word_value


attention_decoder.py

Screen Shot 2016-12-26 at 9.38.00 AM.png

出力部分になります。対話の場合はシステムの応答になります。

入力より複雑になっています。

embed_vocab:出力言語をニューラルネットの空間に写像する部分

embed_hidden:ニューラルネットの値をLSTMに伝搬する部分

hidden_hidden:隠れ層の伝搬部分

annotation_hidden:フォワード型のコンテキストベクトル

back_word_hidden:バックワード型のコンテキストベクトル

hidden_embed:隠れ層から出力層(システムの応答に当たる)への伝搬

embded_target:出力層からシステムの出力(システムの応答に当たる)への伝搬

        super(AttentionDecoder, self).__init__(

embed_vocab=links.EmbedID(vocab_size, embed_size),
embed_hidden=links.Linear(embed_size, 4 * hidden_size),
hidden_hidden=links.Linear(hidden_size, 4 * hidden_size),
annotation_hidden=links.Linear(embed_size, 4 * hidden_size),
back_word_hidden=links.Linear(hidden_size, 4 * hidden_size),
hidden_embed=links.Linear(hidden_size, embed_size),
embded_target=links.Linear(embed_size, vocab_size),
)

出力単語を隠れ層へ写像して微分可能な双極関数を使用

出力単語の隠れ層、隠れ層、コンテキストベクトルのフォワード、コンテキストベクトルのバックワードの総和をlsmに与えて、状態と隠れ層を予測

出力のための隠れ層を先ほど予測した隠れ層を使って微分可能な双極関数で予測する

出力のための隠れ層を使用して出力単語を予測、現在の状態、隠れ層を返す

        embed = functions.tanh(self.embed_vocab(target))

current, hidden = functions.lstm(current, self.embed_hidden(embed) + self.hidden_hidden(hidden) +
self.annotation_hidden(annotation) + self.back_word_hidden(back_word))
embed_hidden = functions.tanh(self.hidden_embed(hidden))
return self.embded_target(embed_hidden), current, hidden


attention_dialogue.py

具体的な対話用の処理を行う部分です。

先ほど説明した4つのモデルを使用します。

embで入力言語をニューラルネットの空間に写像します。

forward_encode:フォワードエンコードしてコンテキストベクトルを作成用に用意します。

back_encdode:バックワードエンコードしてコンテキストベクトルを作成用に用意します。

attention:アテンション用に用意

dec:出力用の単語のために用意

語彙のサイズ、ニューラルネットの空間に写像するためのサイズ、隠れ層のサイズ、XPでgpuを使用するかどうか決めています。


super(AttentionDialogue, self).__init__(
emb=SrcEmbed(vocab_size, embed_size),
forward_encode=AttentionEncoder(embed_size, hidden_size),
back_encdode=AttentionEncoder(embed_size, hidden_size),
attention=Attention(hidden_size),
dec=AttentionDecoder(vocab_size, embed_size, hidden_size),
)
self.vocab_size = vocab_size
self.embed_size = embed_size
self.hidden_size = hidden_size
self.XP = XP

初期化をして勾配をゼロにしています。

    def reset(self):

self.zerograds()
self.source_list = []

入力言語(ユーザーの発話)を単語リストとして保持しています。


def embed(self, source):
self.source_list.append(self.emb(source))

encode処理するための部分です。バッチサイズの取得のために入力言語の1次元部分のみ取っています。



初期化をしていますがgpuとcpuで初期化の値が異なるのでself.XP.fzerosを使用しています。

フォワードのコンテキストベクトル作成のためにフォワードのリストを取得しています。

バックワードも同様の処理を行っています。


def encode(self):
batch_size = self.source_list[0].data.shape[0]
ZEROS = self.XP.fzeros((batch_size, self.hidden_size))
context = ZEROS
annotion = ZEROS
annotion_list = []
# Get the annotion list
for source in self.source_list:
context, annotion = self.forward_encode(source, context, annotion)
annotion_list.append(annotion)
context = ZEROS
back_word = ZEROS
back_word_list = []
# Get the back word list
for source in reversed(self.source_list):
context, back_word = self.back_encdode(source, context, back_word)
back_word_list.insert(0, back_word)
self.annotion_list = annotion_list
self.back_word_list = back_word_list
self.context = ZEROS
self.hidden = ZEROS

フォワード、バックワード、隠れ層のアテンションの層に入れてそれぞれのコンテキストベクトルを取得します。

ターゲットのワード、コンテキスト(decによって得られる)、隠れ層の値、フォワードの値、バックワードの値で出力した単語を返している。


def decode(self, target_word):
annotion_value, back_word_value = self.attention(self.annotion_list, self.back_word_list, self.hidden)
target_word, self.context, self.hidden = self.dec(target_word, self.context, self.hidden, annotion_value, back_word_value)
return target_word

モデルの保存

語彙サイズ、潜在層の写像サイズ、隠れ層のサイズを保存している。


def save_spec(self, filename):
with open(filename, 'w') as fp:
print(self.vocab_size, file=fp)
print(self.embed_size, file=fp)
print(self.hidden_size, file=fp)

モデルの読み込み部分。ここで読み込んだ値を取得してモデルに渡しています。

    def load_spec(filename, XP):

with open(filename) as fp:
vocab_size = int(next(fp))
embed_size = int(next(fp))
hidden_size = int(next(fp))
return AttentionDialogue(vocab_size, embed_size, hidden_size, XP)


EncoderDecoderModelAttention.py

この部分で実際先ほど説明したモジュールを使用

各種パラメータを設定しています。

    def __init__(self, parameter_dict):

self.parameter_dict = parameter_dict
self.source = parameter_dict["source"]
self.target = parameter_dict["target"]
self.test_source = parameter_dict["test_source"]
self.test_target = parameter_dict["test_target"]
self.vocab = parameter_dict["vocab"]
self.embed = parameter_dict["embed"]
self.hidden = parameter_dict["hidden"]
self.epoch = parameter_dict["epoch"]
self.minibatch = parameter_dict["minibatch"]
self.generation_limit = parameter_dict["generation_limit"]
self.word2vec = parameter_dict["word2vec"]
self.word2vecFlag = parameter_dict["word2vecFlag"]
self.model = parameter_dict["model"]
self.attention_dialogue = parameter_dict["attention_dialogue"]
XP.set_library(False, 0)
self.XP = XP

フォワード処理の実装です。

ターゲットとソースのサイズを取得し、それぞれのインデックスを取得しています。


def forward_implement(self, src_batch, trg_batch, src_vocab, trg_vocab, attention, is_training, generation_limit):
batch_size = len(src_batch)
src_len = len(src_batch[0])
trg_len = len(trg_batch[0]) if trg_batch else 0
src_stoi = src_vocab.stoi
trg_stoi = trg_vocab.stoi
trg_itos = trg_vocab.itos
attention.reset()

入力言語を逆方向から入れています。

逆方向から入力すると機械翻訳結果が上がっているので対話も同様の形にしていますが効果はないと思っています。


x = self.XP.iarray([src_stoi('</s>') for _ in range(batch_size)])
attention.embed(x)
for l in reversed(range(src_len)):
x = self.XP.iarray([src_stoi(src_batch[k][l]) for k in range(batch_size)])
attention.embed(x)

attention.encode()

取得したいターゲットの言語列を<s>で初期化しておきます。


t = self.XP.iarray([trg_stoi('<s>') for _ in range(batch_size)])
hyp_batch = [[] for _ in range(batch_size)]

学習部分です。

言語情報はインデックス情報にしないと学習できないのでstoiで言語からインデックス情報に変更します。

ターゲット(この場合は対話の出力)を出して、正解データと比較してクロスエントロピーを計算します。

クロスエントロピーは確率分布間の距離を出しているのでこの計算のロスが少ないほど出力結果がターゲットに近づいていることが分かります。

仮説候補と算出した損失を返しています。


if is_training:
loss = self.XP.fzeros(())
for l in range(trg_len):
y = attention.decode(t)
t = self.XP.iarray([trg_stoi(trg_batch[k][l]) for k in range(batch_size)])
loss += functions.softmax_cross_entropy(y, t)
output = cuda.to_cpu(y.data.argmax(1))
for k in range(batch_size):
hyp_batch[k].append(trg_itos(output[k]))
return hyp_batch, loss

ここはテスト部分になります。

ニューラルネットは無限に候補生成ができ、特にlstmのモデルの場合は過去の状態を利用するため、無限のループに入る可能性があるため制限をかけています。

初期化したターゲットの単語列を利用して出力します。

出力されたデータの最大値を出力してtを更新します。

バッチサイズ分出力された候補をインデックス情報から言語情報に変換しています。

すべての候補が</s>の終了記号で終わっている場合に処理をbreakする。


else:
while len(hyp_batch[0]) < generation_limit:
y = attention.decode(t)
output = cuda.to_cpu(y.data.argmax(1))
t = self.XP.iarray(output)
for k in range(batch_size):
hyp_batch[k].append(trg_itos(output[k]))
if all(hyp_batch[k][-1] == '</s>' for k in range(batch_size)):
break

return hyp_batch

学習全体の処理です。

入力発話と出力発話の初期化を行っています。

self.vocabは全体の語彙でgens.word_listでジェネレータを生成しています。

        src_vocab = Vocabulary.new(gens.word_list(self.source), self.vocab)

trg_vocab = Vocabulary.new(gens.word_list(self.target), self.vocab)

Vocabulary.new()で入力発話と出力発話の語彙情報を作成しています。

gens.word_list(self.source)で下記のようなgeneratorを作成します。self.sourceで与えるのは入力ファイル名になります。

def word_list(filename):

with open(filename) as fp:
for l in fp:
yield l.split()

語彙情報をインデックス情報に変換する処理は下記の部分で行っています。

<unk>は未知語で0、<s>は接頭語で1、</s>は末尾の語句で2を設定しています。

あらかじめにこれらに値を設定しているため+3をしてインデックスが予約語より後になるようにしています。

    @staticmethod

def new(list_generator, size):
self = Vocabulary()
self.__size = size

word_freq = defaultdict(lambda: 0)
for words in list_generator:
for word in words:
word_freq[word] += 1

self.__stoi = defaultdict(lambda: 0)
self.__stoi['<unk>'] = 0
self.__stoi['<s>'] = 1
self.__stoi['</s>'] = 2
self.__itos = [''] * self.__size
self.__itos[0] = '<unk>'
self.__itos[1] = '<s>'
self.__itos[2] = '</s>'

for i, (k, v) in zip(range(self.__size - 3), sorted(word_freq.items(), key=lambda x: -x[1])):
self.__stoi[k] = i + 3
self.__itos[i + 3] = k

return self

アテンションモデルの作成です。

語彙と埋め込み層と隠れ層とXPを与えます。

XPはcpu計算とgpu計算を行う部分になります。


trace('making model ...')
self.attention_dialogue = AttentionDialogue(self.vocab, self.embed, self.hidden, self.XP)

転移学習の部分になります。こちらでword2vecで作成した重みを転移させています。

word2vecで作成したモデルの重みの名前weight_xiなので入力発話は統一していますが出力発話の部分はembded_targetで異なるため下記の処理を入れています。

[0]の部分は重みの名前

[1]の部分は値になります。

                if dst["embded_target"] and child.name == "weight_xi" and self.word2vecFlag:

for a, b in zip(child.namedparams(), dst["embded_target"].namedparams()):
b[1].data = a[1].data

重みのコピー部分です。

元となる部分のイテレーションを回し、条件が一致する場合は重みのコピーをします。

条件1:重みに命名された名前に一致するものがあること

条件2:重みの型が同一であること

条件3:link.Linkの部分つまりモデル部分まで到達していること

条件4:モデルの重みの行列の長さが同一であること


def copy_model(self, src, dst, dec_flag=False):
print("start copy")
for child in src.children():
if dec_flag:
if dst["embded_target"] and child.name == "weight_xi" and self.word2vecFlag:
for a, b in zip(child.namedparams(), dst["embded_target"].namedparams()):
b[1].data = a[1].data
print('Copy weight_jy')
if child.name not in dst.__dict__: continue
dst_child = dst[child.name]
if type(child) != type(dst_child): continue
if isinstance(child, link.Chain):
self.copy_model(child, dst_child)
if isinstance(child, link.Link):
match = True
for a, b in zip(child.namedparams(), dst_child.namedparams()):
if a[0] != b[0]:
match = False
break
if a[1].data.shape != b[1].data.shape:
match = False
break
if not match:
print('Ignore %s because of parameter mismatch' % child.name)
continue
for a, b in zip(child.namedparams(), dst_child.namedparams()):
b[1].data = a[1].data
print('Copy %s' % child.name)


if self.word2vecFlag:
self.copy_model(self.word2vec, self.attention_dialogue.emb)
self.copy_model(self.word2vec, self.attention_dialogue.dec, dec_flag=True)

入力発話と出力発話のジェネレーターを作成します。

            gen1 = gens.word_list(self.source)

gen2 = gens.word_list(self.target)
gen3 = gens.batch(gens.sorted_parallel(gen1, gen2, 100 * self.minibatch), self.minibatch)

両者をバッチサイズ分作成しておきます。

下記でタプル形式でバッチサイズ分作成します。

def batch(generator, batch_size):

batch = []
is_tuple = False
for l in generator:
is_tuple = isinstance(l, tuple)
batch.append(l)
if len(batch) == batch_size:
yield tuple(list(x) for x in zip(*batch)) if is_tuple else batch
batch = []
if batch:
yield tuple(list(x) for x in zip(*batch)) if is_tuple else batch

入力発話と出力発話をバッチサイズ分作成してソートしています。


def sorted_parallel(generator1, generator2, pooling, order=1):
gen1 = batch(generator1, pooling)
gen2 = batch(generator2, pooling)
for batch1, batch2 in zip(gen1, gen2):
#yield from sorted(zip(batch1, batch2), key=lambda x: len(x[1]))
for x in sorted(zip(batch1, batch2), key=lambda x: len(x[order])):
yield x

最適化にはAdagradを使用しています。

単純に更新回数が累積するほど更新幅が小さくなる手法です。

r ← r + g^2_{\vec{w}}\\

w ← w - \frac{\alpha}{r + }g^2_{\vec{w}}

optimizer.GradientClipping(5)でL2正則化を使用して勾配が一定の範囲内になるように抑えています。


opt = optimizers.AdaGrad(lr = 0.01)
opt.setup(self.attention_dialogue)
opt.add_hook(optimizer.GradientClipping(5))

下記で入力ユーザー発話と対応ユーザー発話をfill_batchにより*で穴埋めして深層学習可能な形にします。

def fill_batch(batch, token='</s>'):

max_len = max(len(x) for x in batch)
return [x + [token] * (max_len - len(x) + 1) for x in batch]

フォワード処理で得られた損失を使ってバックワード処理を行い、重みの更新を行う。

バックワードの処理は活性化関数によって異なる。

更新部分は下記のようになっている。

データをgpuで処理するかcpuで処理するか変えて

tupledict、それ以外でデータの与え方を変えて、損失関数による最適化を変えている。


def update_core(self):
batch = self._iterators['main'].next()
in_arrays = self.converter(batch, self.device)

optimizer = self._optimizers['main']
loss_func = self.loss_func or optimizer.target

if isinstance(in_arrays, tuple):
in_vars = tuple(variable.Variable(x) for x in in_arrays)
optimizer.update(loss_func, *in_vars)
elif isinstance(in_arrays, dict):
in_vars = {key: variable.Variable(x)
for key, x in six.iteritems(in_arrays)}
optimizer.update(loss_func, **in_vars)
else:
in_var = variable.Variable(in_arrays)
optimizer.update(loss_func, in_var)

            for src_batch, trg_batch in gen3:

src_batch = fill_batch(src_batch)
trg_batch = fill_batch(trg_batch)
K = len(src_batch)
hyp_batch, loss = self.forward_implement(src_batch, trg_batch, src_vocab, trg_vocab, self.attention_dialogue, True, 0)
loss.backward()
opt.update()

学習したモデルの保存

saveとsave_specはchainerの標準では存在せず、言語に関する情報の保存のため、別に作成している。

saveは発話データの情報を保存

save_specは語彙のサイズや埋め込み層のサイズ、隠れ層のサイズの保存

save_hdf5はhdf5フォーマットでモデルを保存


trace('saving model ...')
prefix = self.model
model_path = APP_ROOT + "/model/" + prefix
src_vocab.save(model_path + '.srcvocab')
trg_vocab.save(model_path + '.trgvocab')
self.attention_dialogue.save_spec(model_path + '.spec')
serializers.save_hdf5(model_path + '.weights', self.attention_dialogue)

テストの部分です。

学習の際に出力されたモデルを読み込んで入力発話に対するユーザーの発話内容を出力しています。


def test(self):
trace('loading model ...')
prefix = self.model
model_path = APP_ROOT + "/model/" + prefix
src_vocab = Vocabulary.load(model_path + '.srcvocab')
trg_vocab = Vocabulary.load(model_path + '.trgvocab')
self.attention_dialogue = AttentionDialogue.load_spec(model_path + '.spec', self.XP)
serializers.load_hdf5(model_path + '.weights', self.attention_dialogue)

trace('generating translation ...')
generated = 0

with open(self.test_target, 'w') as fp:
for src_batch in gens.batch(gens.word_list(self.source), self.minibatch):
src_batch = fill_batch(src_batch)
K = len(src_batch)

trace('sample %8d - %8d ...' % (generated + 1, generated + K))
hyp_batch = self.forward_implement(src_batch, None, src_vocab, trg_vocab, self.attention_dialogue, False, self.generation_limit)

source_cuont = 0
for hyp in hyp_batch:
hyp.append('</s>')
hyp = hyp[:hyp.index('</s>')]
print("src : " + "".join(src_batch[source_cuont]).replace("</s>", ""))
print('hyp : ' +''.join(hyp))
print(' '.join(hyp), file=fp)
source_cuont = source_cuont + 1

generated += K

trace('finished.')


まとめ

PyCon 2016で発表した内容ですが、これでも一部だと思うと他の部分の説明も合わせると道のりが長そうです。

現状で単純な深層学習で対応できる範囲は限られているので、複合的な技術を使って対応しています。

深層学習における対話用のモデルが多数出てきている状況なので評価指標を確定して、深層学習のモデルを変更していくことで性能向上に繋がっていくと思います。


参考

Attention and Memory in Deep Learning and NLP