はじめに
昔まだChainerがver1や2だった頃、Sequence to sequence (以降、seq2seq)モデルの実装に四苦八苦していました。
ましてや、Attention Seq2seqを組むなんて夢のまた夢でした。(まあそれでも組みましたが
という過去にものすごく苦戦した思い出があるのですが、最近Seq2seqモデルを利用する機会があり、せっかくなので昔のモデルを使いまわしをせずに最新のChainerの恩恵を受けた新しいモデルを作成することにしましたとさ。
環境など
- Python 3.7.2
- Chainer 5.3.0(投稿時ver6も出てますが、satableで最新ということで・・・)
通常のSeq2Seqモデル
Chainer公式からexampleが出ているので、そちらを参照されたい。
https://github.com/chainer/chainer/tree/master/examples/seq2seq
なお、この記事は↑のサンプルがある程度理解できる方を対象にしています。
学習でのSeq2seqモデルの要点をまとめると、
class Seq2seq(chainer.Chain):
(中略)
def forward(self, xs, ys):
(中略)
# None represents a zero vector in an encoder.
hx, cx, _ = self.encoder(None, None, exs)
_, _, os = self.decoder(hx, cx, eys)
(以下略)
なんと、seq2seqの複雑なネットワークがたった2行で記述できてしまいます。(昔はもっと大変だったのになぁ・・・
もちろん、encoderやdecoderの定義、入力データの処理は必要ですが、それでも随分と楽になったものです。
exs
へは要素が入力文の単語ベクトルのnumpy.arrayをミニバッチサイズ分listにしたもので、os
にはeys
の長さに対応する隠れ層のベクトルが出力されます。
shapeっぽいのは以下の通り。(listなので.shapeは使えません)
- exs's shape -> [(input_len, word_vector) * batch_size]
- eys's shape -> [(output_len, word_vector) * batch_size]
- os's shape -> [(output_len, hidden_layer_vector) * batch_size]
Attention化する
元論文(さらに大元があるけど)であるEffective Approaches to Attention-based Neural Machine Translationは、以下の3つの要素から成り立っています。
- Global Attention
- Local Attention
- Input Feeding
一般的にAttentionと呼ばれるものは(1)と(2)で、(3)はおまけみたいなものです(入れようと思えば通常のSeq2seqでも導入できます)。
実際に評価では(3)を入れたり入れなかったりして性能を確かめています(なお入れたほうが性能は高い模様)。
今回は以下の理由で一番簡単なGlobal Attentionのみを実装します。
- 適応予定のデータの文長が短い(高々10単語 → Local Attentionの効果は低い?)
- リソースの兼ね合い上、RNNのレイヤーを高く積まない(1層の予定 → Input Feedingの効果は低い?)
- 学習速度を重視したい(すべての処理をミニバッチ単位で処理し、forループを避けたい)
できるだけ実装をさぼりたい
実際のコード
ソースコード全体はGithubに置いてあります。
import numpy
import chainer
import chainer.functions as F
import chainer.links as L
UNK, EOS, BOS = 0, 1, 2
class AttentionSeq2Seq(chainer.Chain):
def __init__(self, n_layers, n_source_vocab, n_target_vocab, n_units):
super(AttentionSeq2Seq, self).__init__()
with self.init_scope():
self.embed_x = L.EmbedID(n_source_vocab, n_units, initialW=chainer.initializers.Uniform(.25), ignore_label=-1)
self.embed_y = L.EmbedID(n_target_vocab, n_units, initialW=chainer.initializers.Uniform(.25), ignore_label=-1)
self.encoder = L.NStepLSTM(n_layers, n_units, n_units, 0.1)
self.decoder = L.NStepLSTM(n_layers, n_units, n_units, 0.1)
self.Wd = L.Linear(n_units, n_target_vocab)
self.Wa = L.Linear(n_units*2, n_units)
self.attention_hw = L.Linear(n_units*2, 1)
self.n_layers = n_layers
self.n_units = n_units
self.n_inf = -100000000
def forward(self, xs, ys, train=True):
x_len_lst = [len(x) for x in xs]
y_len_lst = [len(y) for y in ys]
batch_size, x_len, y_len = len(xs), max(x_len_lst), max(y_len_lst) + 1
# shaping inputs
xs = [self.xp.array(x[::-1], numpy.int32) for x in xs]
eos = self.xp.array([EOS], numpy.int32)
bos = self.xp.array([BOS], numpy.int32)
ys_in = [F.concat([bos, self.xp.array(y, numpy.int32)], axis=0) for y in ys]
ys_out = [F.concat([self.xp.array(y, numpy.int32), eos], axis=0) for y in ys]
# Both xs and ys_in are lists of arrays.
exs = sequence_embed(self.embed_x, xs)
eys = sequence_embed(self.embed_y, ys_in)
# basic encoder & decoder
hx, c, xos = self.encoder(None, None, exs)
_, _, yos = self.decoder(hx, c, eys)
# shaping for attention
xo = F.pad_sequence(xos, padding=0.)
yo = F.pad_sequence(yos, padding=0.)
xo = F.reshape(xo, (batch_size, x_len, 1, self.n_units))
yo = F.reshape(yo, (batch_size, y_len, 1, self.n_units))
yo = self.global_attention(xo, yo, x_len, y_len, batch_size)
yos = [F.reshape(h[:, :len(y), :], (len(y), h.shape[-1]))
for h, y in zip(F.split_axis(yo, batch_size, axis=0), ys_out)]
# calculate loss
concat_os = F.concat(yos, axis=0)
concat_ys_out = F.concat(ys_out, axis=0)
loss = F.sum(F.softmax_cross_entropy(
self.Wd(concat_os), concat_ys_out, reduce='no')) / batch_size
def global_attention(self, xo, yo, x_len, y_len, batch_size):
def _batch_axis(v):
return len(v.shape) - 1
# shaping for calculate weighted average
eh = F.repeat(xo, y_len, axis=1)
hh = F.tile(yo, (1, x_len, 1, 1))
h = F.concat([eh, hh], axis=-1)
cond = self.xp.concatenate([self.masking(eh), self.masking(hh)], axis=-1)
h = F.where(cond, F.tanh(h), self.xp.full(cond.shape, 0., self.xp.float32))
# apply attention Liner W and softmax
h = self.attention_hw(h, n_batch_axes=_batch_axis(h))
h = F.reshape(h, (batch_size, x_len, y_len, 1))
cond = self.xp.reshape(self.xp.all(cond, axis=-1), h.shape)
h = F.where(cond, h, self.xp.full(h.shape, float(self.n_inf), self.xp.float32))
h = F.softmax(h, axis=1)
h = F.where(cond, h, self.xp.full(h.shape, 0., self.xp.float32))
# Calculate weighted average
h = h[:, :, :, :, None]
h = F.repeat(h, self.n_units, axis=-1)
xo = xo[:, :, None, :, :]
xo = F.repeat(xo, y_len, axis=2)
h = F.sum(h * xo, axis=1)
# Apply weighted average to yo
cond = self.xp.logical_and(self.masking(yo), self.masking(h))
h = F.concat([yo, h], axis=-1)
h = F.tanh(self.Wa(h, n_batch_axes=_batch_axis(h)))
h = F.where(cond, h, self.xp.full(h.shape, 0., self.xp.float32))
# Shaping input form
h = F.reshape(h, (batch_size, y_len, self.n_units))
return h
def masking(self, x):
# making mask for padding
m = self.xp.sum(self.xp.absolute(x.data), axis=-1) > 0.
m = m[:, :, :, None]
m = self.xp.tile(m, (1, 1, 1, x.shape[-1]))
return m
約110行!う~ん・・・あんまり楽じゃないかな?
ミニバッチ単位で処理しようとすると、私のコーディング力(ちから)ではこの辺が限界です。なお、一部無駄っぽいような冗長っぽいようなところは、隠れ層の次元を変えたりRNNモデルを切り替えやすくするため(要するにわざと)だったりします(詳しくはGitHubのソースを見てください)。
とはいうものの、昔よりだいぶシンプルに書くことができました。ありがたや~ありがたや~
まとめ
最新のChainerを使ってAttention Seq2seqモデルを実装しました。
昔のChainerが公開された当時に比べると、非常にシンプルに記述できるようになりました。
今後の改良・発展にも期待しています!