(Chainer)NLPerのためのConvolution1Dの使い方

自然言語処理やっててまれによくつかうCNN

やりたいこと

文字分散表現を畳み込んで単語分散表現を作りたい。
もちろん単語レベルで畳み込んで文をエンコードするのにも使える。

I
love
you

という単語列をそれぞれ窓幅3で畳み込み、tanhしてmax poolingしたい。
例えばIは適当なpaddingを使って「..I..」とし、「..I」「.I.」「I..」の三つを変換する。

links.Convolution1D

まずConvolution1Dの説明。基本的にはConvolutionNDの特殊な場合なのでここを見ればわかる。
宣言は以下の通り。

from chainer import links as L
in_channels = 5 # embedding size
out_channels = 3 # 畳み込み後のベクトルのサイズ
ksize = 3 # 窓幅
stride = 1 # ずらす間隔
pad = ksize-1 # 前後のpadding数
conv = L.Convolution1D(in_channels, out_channels, ksize, stride, pad)

CNNでは窓をスライドさせながら、窓内のベクトルを変換していく。
pad=ksize-1にしておけば、入力の長さと出力の長さが揃う。

入力

Convolution1Dへの入力はテンソル。
後述する通りNStepLSTMとは異なり入力がリストではない上、ベクトル並べ方が違うので注意。

I, love, youに対応するembeddingが以下の通りだとする。

import numpy as np
from chainer import functions as F
vi = np.array([[1]*5],’f’)
vl = np.array([[2]*5],’f’)
vo = np.array([[3]*5],’f’)
vv = np.array([[4]*5],’f’)
ve = np.array([[5]*5],’f’)
vy = np.array([[6]*5],’f’)
vu = np.array([[7]*5],’f’)

ems = [F.vstack([vi]), F.vstack([vl, vo, vv,ve]), F.vstack([vy, vo, vu])]
# emsの中身
[
variable(
[[1., 1., 1., 1., 1.]]),
variable(
[[2., 2., 2., 2., 2.],
[3., 3., 3., 3., 3.],
[4., 4., 4., 4., 4.],
[5., 5., 5., 5., 5.]]),
variable(
[[6., 6., 6., 6., 6.],
[3., 3., 3., 3., 3.],
[7., 7., 7., 7., 7.]])
]

emsはI love youに対応して、長さが1,4,3となるembeddingの束。

convに投げるために0埋めで長さを揃える。

ems_pad = F.pad_sequence(ems)
# ems_padの中身
variable(
[[[1., 1., 1., 1., 1.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]],

[[2., 2., 2., 2., 2.],
[3., 3., 3., 3., 3.],
[4., 4., 4., 4., 4.],
[5., 5., 5., 5., 5.]],

[[6., 6., 6., 6., 6.],
[3., 3., 3., 3., 3.],
[7., 7., 7., 7., 7.],
[0., 0., 0., 0., 0.]]]
)

さらに、Convolution1Dは左から右に時系列順で列ベクトルを並べる必要がある。
transposeでくるっとしてやる。

ems_pad_t = F.transpose(ems_pad, (0,2,1))
# ems_pad_tの中身
variable(
[[[1., 0., 0., 0.],
[1., 0., 0., 0.],
[1., 0., 0., 0.],
[1., 0., 0., 0.],
[1., 0., 0., 0.]],

[[2., 3., 4., 5.],
[2., 3., 4., 5.],
[2., 3., 4., 5.],
[2., 3., 4., 5.],
[2., 3., 4., 5.]],

[[6., 3., 7., 0.],
[6., 3., 7., 0.],
[6., 3., 7., 0.],
[6., 3., 7., 0.],
[6., 3., 7., 0.]]]
)

この時、テンソルems_pad_tのshapeは順番に(バッチサイズ, embedding_size, max_length)となることを確認。
バッチサイズと言っているが、ここではすなわち入力したい単語数。
また、embedding_sizeはすなわちin_channelsとして指定した引数。

Convolution1Dの宣言時にpad=2とかにしておくと、たたみこむ時に行列の左右が勝手にpaddingされる。
つまり、二つ目のloveの左右に列ゼロベクトルが二つずつくっついて、..love..のようになる。

出力

上記で作ったems_pad_tをconvに突っ込むと、以下の様に(バッチサイズ, out_channels, max_length+pad*2-ksize+1)ってかんじのベクトルが出てくる。
ems_pad_tの左右に2つゼロベクトルくっつけたものを左から右に、窓幅3でずりずりと変換して行った結果が並ぶ。

ys = conv(ems_pad_t)
# ysの中身
variable(
[[[ 0.42356437, 0.3952442 , 0.27475464, 0. , 0. , 0. ],
[ 0.20916435, 0.6197737 , -0.05764255, 0. , 0. , 0. ],
[ 0.7326252 , -0.39324343, -0.59779274, 0. , 0. , 0. ]],

[[ 0.84712875, 2.0611815 , 3.4294996 , 4.5230627 , 3.0752394 , 1.3737733 ],
[ 0.4183287 , 1.8670403 , 2.5806937 , 3.3519888 , 2.8682985 , -0.28821275],
[ 1.4652504 , 1.4113886 , 0.5551846 , 0.29677433, -4.357388 , -2.9889636 ]],

[[ 2.5413861 , 3.6421587 , 5.7992115 , 3.5909734 , 1.9232826 , 0. ],
[ 1.2549863 , 4.346136 , 2.9776158 , 4.1654882 , -0.40349782, 0. ],
[ 4.395751 , -0.16158503, 0.36188918, -4.5460825 , -4.1845493 , 0. ]]]
)

あとは非線形関数を通してmax_poolingすれば良い。

ys_tanh = F.tanh(ys)
ys_tanh_mp = F.max_pooling_1d(ys_tanh, ksize=ys_tanh.shape[2])
# ys_tanh_mpの中身
variable(
[[[0.39992893],
[0.5509704 ],
[0.6246687 ]],

[[0.9997643 ],
[0.99755096],
[0.8986675 ]],

[[0.99998164],
[0.9996643 ],
[0.999696 ]]]
)

Convolution1Dの出力なので、max_pooling_1dを使うと楽。
各行列を行方向にmax poolingして、一つの列ベクトルにまとめる。
行列を左右からギュッと押して、一番大きい数字だけが出てきたと思えばいい。

Chainerでは行ベクトルが基本なので、向きを直しておく。

word_ems = F.reshape(ys_tanh_mp, (ys_tanh_mp.shape[0], -1))
# word_emsの中身
variable(
[[0.39992893, 0.5509704 , 0.6246687 ],
[0.9997643 , 0.99755096, 0.8986675 ],
[0.99998164, 0.9996643 , 0.999696 ]]
)

文字分散表現よりも単語分散表現の方がサイズが小さいのは意味がわからないが、面倒なのでこれでword embeddingということにさせてほしい。
こんな感じでConvolution1Dを使うと良さそう。

embeddingの時点でpaddingもしてしまうとbackwardが速くなる

残りは実装上の話。
今回のようにembeddingをいきなり畳み込むなら、リストにしてからpad_sequenceを使うよりも最初からpadされたものを用意した方が良い。
L.EmbedIDのignore_labelを使えば、pad_sequence後と同じ計算結果を得られる。

lookup_table=np.vstack([vi,vl,vo,vv,ve,vy,vu])
# lookup_tableの中身
array(
[[1., 1., 1., 1., 1.],
[2., 2., 2., 2., 2.],
[3., 3., 3., 3., 3.],
[4., 4., 4., 4., 4.],
[5., 5., 5., 5., 5.],
[6., 6., 6., 6., 6.],
[7., 7., 7., 7., 7.]], dtype=float32)

embed = L.EmbedID(7, 5, initialW=lookup_table, ignore_label=-1)

xs = np.array([
[0,-1,-1,-1],
[1,2,3,4],
[5,2,6,-1]], ‘i’)
ems = embed(xs)

# emsの中身
variable([[[1., 1., 1., 1., 1.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]],

[[2., 2., 2., 2., 2.],
[3., 3., 3., 3., 3.],
[4., 4., 4., 4., 4.],
[5., 5., 5., 5., 5.]],

[[6., 6., 6., 6., 6.],
[3., 3., 3., 3., 3.],
[7., 7., 7., 7., 7.],
[0., 0., 0., 0., 0.]]])

I love youはそれぞれ[0,1,2,3,4,5,2,6]というインデックスに置き換えた上で、もっとも長いloveに合わせて空いた部分を-1で埋めた。
ここでindexと分散表現の数字が一つずれることに気づいたが、仕方ない。
あとはEmbedIDに投げてやれば、-1の部分は0埋めになる。便利。

畳み込みした時に、窓内が全部0になる部分は捨てた方がよくない?

たとえば「love」と一緒に「I」を畳み込む時は、Iは「..I…..」というpadding処理を受ける。
ここのまま畳み込むと、窓幅3の中身が全部「…」になる部分が出てくる。
この計算結果がなかなか大きいと、max_pooling処理でなんの情報もないpadding部分の計算結果が取れてしまうことがある。

もちろん学習が進めばpadding部分の数値は小さくなるはずだが、テスト時などの未知の系列に対してそれが正しく働くかはわからない。
本当はこういうpadding処理はあとでマスクしたりした方がいいはずだが、よくわからない。

TwitterでChainer強い諸氏に話を伺ったけど、まあええやろという気持ちになってるらしい。まあええか。

(Chainer)NLPerのためのConvolution1Dの使い方」への1件のフィードバック

  1. ピンバック: 2019/10/07-10/13 | 熊日記

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です