Transformer (トランスフォーマー)

Transformer (トランスフォーマー)のアルゴリズムについて解説します。Transformer はNLP界だけでなく画像処理界でとても話題となりました。

2015年Sequence2Sequence でNLP界隈は盛り上がりましたが、2017年にAttention all you needを発表し、 Transformerという新しいモデルが生まれ、学習時間面と精度でSeq2Seqを抜き去り更なる盛り上がりを見せたのです。

TransformerはDeep Learning 研究の中でも非常に大きなブレイクスルーを起こした技術の1つとなりました。(TransformerもAttentionというSeq2Seqで注目された技術をつかっているため、全ては必然で誕生してきた技術という感じもします)個人的には2021,2022、2023年の最新技術にふんだんに使われており、この技術がなくしては今のChatGPTなどもありません。

最新の翻訳技術で有名な所はTransformerの派生、改良版みたいなものです。2018年末にはGoogleのBERT、2020年4月にOpenAIからGPT-3などが発表されていますが今回説明するTransformerの技術が礎となっています。

画像処理に関しても、Stable diffusionのエンコーダーではTransformerが使われていますし、入力には単眼カメラによる深度マップが必要なのですが、それを作成するにも、MiDas などのDPT(Dense Prediction Transformer)アーキテクチャベースが使われるのですが、名前の通りTransformerが使われています。

こうした最先端の技術の礎となっているTransformerについて、本ブログではソースコードをメインに解説していきたいと思います。

Transformer

トランスフォーマーが産まれたのは、雑に言えばSeq2Seq(+Attention)を並列化して高速に学習したかったからです。(Seq2seq+Attentionの概略については、後ほど説明します)

並列化と聞いてまず思い浮かぶのがCNNなどコンボリューションです。並列処理が得意なGPUに向けて行列演算に落とし込めば並列化が見込めます。しかし、Seq2Seq+Attentionはバケツリレー形式で情報を運んでいる(前の入力に対しての処理をしている)ので並列化はできません。

そこでTransformerでは、Seq2SeqのAttentionを、self-attention層という独立して計算する層をつくり、行列演算を用いて並列化を実現しました。

Seq2Seqでは全体でAttentionを実現していましたが、Transformerではattention層という単位に落とし込んで実現しています。下記に簡単にTransformerの全体図を示します。

トランスフォーマー全体図。例では左のエンコーダーは2段。右のデコーダー1段構成。Encoder最終段の出力値をDecoderで使うことになる。なお、実際の論文ではEncoder6段、Decoder6段の構成となる。
右側DecoderのOutputでは最後にSoftmax処理が入る。Encoder,Decoderで、Self-attention層があり今までのSeq2Seqとは様相が異なる形がわかる

上図がTransformerの全体図になります。Encoder部とDecoder部の2部構成であり、これはSeq2Seqと同じです。また、Encoder,Decoderとも多段で接続(オリジナルの論文では6段)で構成しています。

Transformerでの肝はずばりSelf-attention層となっています。Self-attention層は同じセンテンス内の単語群が、翻訳したい単語にどれだけ影響しているかというものを検出します。

Transformerを構成しているEncoderとDecoderともに、Self-attetion層やNormalization層など同じ層を使って構成しています。Decoderにしかない特徴的な層があり、それは、Encoder-decoder attention層となります。後ほど説明しますが、Self-attention層とやることは同じなのですが、Encoder側からバイパスしてもらう特徴量(最後の段のEncoderからの入力)を用いて2つの特徴行列(Key, Valueを生成する行列)を学習し、Self attentionを行っています。これにより入力のどのパーツに注目するべきかを考えることができます。全てのDecoder群のEncoder-decoder attention層では、最後のEncoderが出力した特徴を使うことになります。それでは各層の流れを説明していきます。

Transformer 各層の説明と流れ

Transformer内部のEncoder, Deocder内部の層がどのような処理をしているかを記載していきます。それぞれの層の説明、特にSelf-attentionはなかなか濃ゆいと思うのでじっくり理解していってください。

Encoder、Decoderそれぞれでは

  1. 事前処理Embedding
  2. 事前処理Positional Encoding
  3. self-attention層( + Encoder-Decoder attention層( decoderのみ ) )
  4. Normalization層
  5. Feed forward層

というような処理を順次行っています。それぞれの処理について説明していきます。

1.【事前処理】Embedding処理

Embeddingは文字の埋め込みを行います。Word2Vecの様な処理です。具体的には例えば512次元のベクトルに単語を変換します。例えば I am an studentであれば、

I = [ 0.1, 0.2, 0.03, 0.78 …. 0.04 ], am = [ 0.41, 06., 0.033, 0.378 …. 0.3 ], an = [ 0.41, 0.52, 0.073, 0.5678 …. 0.34 ], student = [ 0.81, 0.62, 0.023, 0.478 …. 0.54 ]

のように一つ一つの単語に対してベクトルに変換をします。pytrochではEmbeddingは関数一つで実現でき、上記例では512次元のベクトルに変換するのにembedding = nn.Embedding(4, 512)としておき、embeddingを呼ぶことで実現できます。Embeddingの方法やアルゴリズムについては[10]を参照してみてください。要するにここで文字列をベクトル化してやって学習するようにするのです。

2.【事前処理】Positional Encoding(PE)処理

Positional Encoding (PE)は順序の定義を行います。今回のTransformerでは行列演算により並列化し従来よりも高速に計算をするという目的がありました。並列に行列計算を行うため、文章の単語に対して順序(もとにいた位置)を考慮させる必要があります。Seq2Seqのようにバケツリレーではないので、バラバラにしてももとの位置が解るような情報量を与える必要があります。

順序でいえば、例えば0,1,2,3とindex値を割り振ればいいと思うかもしれませんが、文字数が大きくなると、値も大きなってしまいます。そもそも-1〜1の範囲で正規化して学習するため整数のindex値は相性が悪そうです。じゃあ正規化をすれば良いのではないかと思うのですが、今度は別の問題が発生します。例えば、5文字のセンテンスがあったとして0.8は四番目の文字を意味します。所が10文字の0.8は8番目になります。つまり、同じ値に場所が違う問題が発生します。

では、文字列の次元を固定(例えば512次元)して、割り振ればいいと思うかもしれませんが、ほとんどの文章は20単語くらいでしょう。そうすると、ものすごい無駄な空間(20次元以降の情報は使われることは殆ど無い)となります。

さて、こうした問題を解決したのが余弦、正弦を使った順序の定義です。数式は次のとおり表されます。

上記式を例えば128次元(d_model)と仮定すると、以下のような画像となります。[11]

positional encoding
各行が文字の位置のベクトルとなる。埋め込みされたベクトルと足し合わせる

Embeddingしたinputを、このPositional Encodeing の値と足し込めたものをインプットとして利用します。疑似コードを書くと次のとおりです。

x = Embedding( input ) + PositionalEncoding( input )

上記のように簡単に書くと足しこむだけです。実際に疑似コードでなくPyTorchで実装を書くと以下のような実装になっています。

import torch.nn as nn
import math
import torch
class PositionalEncoder(nn.Module):
    def __init__(self, word_num, dim ):
        super().__init__()
        self.dim = dim
        
        # word_num行, dim列のゼロベクトルのpe(positional encoding)の作成
        pe = torch.zeros(word_num, dim)
        for pos in range(word_num):
            for i in range(0, dim, 2):
                pe[pos, i] = math.sin(pos / (10000 ** ((2 * i)/dim)))
                pe[pos, i + 1] = math.cos(pos / (10000 ** ((2 * (i + 1))/dim)))

        # 行列[ [ ...],[....],,, ]を更に囲む(Tensor化)
        pe = pe.unsqueeze(0)
    
    def forward(self, data):
        data = data * math.sqrt(self.dim) # ちょっと値を大きくしてやる
        seq_len = data.size(1) # dataのワード数を取り出す
        data = data + torch.tensor(self.pe[:,:seq_len], requires_grad=False) # 足し込む処理
        return data

3.Self-attention層

そもそもAttentionとは

Transformerを学ぶ前にTransformerの目玉の一つでもあるAttentionという技術はどういう技術か紹介します。

「Attention」は注目という意味となります。つまるところAttentionとは、入力の注目する部分を見つける!ということになります。

画像処理で言えば、学習時に犬だけに注目して処理したもの(犬を全景処理で切り出したもの)と、画像全体で処理したものを使っての学習では、効率や結果が変わりそうです。犬だけに注目したい!というのがAttentionという技術となります。この技術は文章にも適用ができ、例えば翻訳の部分では翻訳する際にどこにまず注目した方が良いのか、や、文章の単語同士の関わりの指標みたいなのを抽出することが出来るようになるのです。

言葉で説明してもわかりにくいかもしれません。この技術をYoutubeで15分で紹介している方が居ました。素晴らしく解りやすいので、是非ご覧になってください。

Attentionは対象物のみに集中する、という意味で逆に言えばそれ以外を無視するという雑な言い方も出来ます。実際に我々の脳でも同じことをやっています。例えば集合写真で人数を数えてくれって言われたら、頭の数を数えてほかは無視するようにすると思います。このように、人間と同じようなことをするという方法がAttentionという方法です。

Seqence to sequence with the Attention

実際のAttentionの使われ方をSeq2Seqモデルから考えてみます。Seq2Seqモデルで翻訳を前回の記事では実装しました。Seq2Seqは素晴らしい技術ですが長い文章の翻訳が弱いという弱点があります。それはエンコーダーからデコーダーへ渡されるものがContextベクトルだけであるため、例えば翻訳の最初の部分の表現が弱くなってしまう可能性があるという問題点があったためです。Seq2Seqのモデルはわかりやすく表すと下図のような仕組みとなっていました。なおSeq2Seqに関しては前回の記事を参照ください

Seq2Seqの流れ。コンテキストがバケツリレーでエンコーダーからデコーダーへと渡されている。図は参考文献[1]より

Attentionはこうした問題に対応するべく産まれました[ 2 Bahdanau et al, 2015 ]。Seq2Seqで実際に、どのようなことをAttentionを実現しているかと言えば、雑に言うと、seq2seqのエンコードの部分で利用したHiddenベクトルを、まとめてデコーダー部分にわたしている、というのが特徴になります。上のSeq2Seqのモデルと比較してみてください。

Seq2Seq with Attention方式。Encoderで利用したHiddenベクトルもわたし、そのベクトル群から注目するワードは何かという処理をDecoder内で行う(Attention処理)

Seqence to seqence でAttentionの実現には、デコーダー内でまとめて渡されたHidden ベクトルの内どれが重要であるかを単語単位で調べていき、重み付けをしてSoftmaxし、加重平均しています。詳細な流れですが長くなるので説明しません。というのも、その方法よりもTransformerの方が精度があがっているため、いまさらSeq2SeqのAttentionの実装方式にフォーカスするよりは、Transformerを学んだほうが有用であるためです。(なにより、Transformerの説明の記事なので)

Seq2Seq with Attentionの実現方法については、リクエストが有れば別の機会にご紹介できればと思います。なお、Seqence to sequence のAttentionの実装方法も奥が深く、Global, local attentionなどの手法も提案されています。

Self-attention層の詳細

肝のSelf attention層です。センテンスにある各単語同士の注目度を算出していきます。注目度の算出は雑に言うと内積で求めていきます。

Self-Attentionでは、Embedding+PEされたベクトル(入力ベクトルと呼ぶことにする)から、Query, Key, Valueという3つのベクトルを作成します。

この3つのベクトルを作るためには3つの行列が必要なのですが、Self-attention層ではその3つの行列を学習で作ることが目標です。入力はすべてEmbedding+PEしたベクトルxとなります。つまり、このxに、学習で作られた行列key matrix, value matrix, query matrix を掛け合わせることで、それぞれQuery, Key, Valueベクトルを作るのです。

注目度である内積値,attentionはQuery, Key, Valueのベクトルから求めます。3つのベクトルの次元数は全て同じです。

ここでいうQuery, Key, Valueとは一体なんでしょうか? Query, Key, Valueは、同じ入力ベクトルxから生成されます。Self-Attentionという技術では、自身の入力単語群同士がどこに注目度が高くなるべきか、例えば翻訳では、様々な翻訳結果を元にきちんとした結果が算出されるような(内積値が高くなるような)行列を生み出すかを集中します。やっていることのイメージはと言うと、学習では入力ベクトルをぐるんぐるん回転させたり伸ばしたりする行列を生成し、ベクトル同士での内積値が正しく反応するように学習していっているイメージとなります。

結果として、その行列と入力ベクトルから算出したq, k, vベクトルは、学習内容に対しては、特定のセンテンス内のワード同士のAttention(内積値が高い)が完成し、例えば翻訳では、未知の入力に対してもAttentionを正確に算出することで、正確な翻訳の手助けをすることができる、という算段です。

説明が難しいのですが、Query, Key, Valueについて、何で3つに分解するんだろう、それぞれの持つ意味は何だろうかと考えすぎると思考停止に陥いってしまうので、今は取り敢えず深く考えずに3つに分解する、それを以ってなんとかSelf-attentionを実現する、という考えで読み勧めてください。

それでは、実際にどのようにSelf-attentionが実装しているのか見てみます。

Step 1:次元圧縮

今ここで, You love me( お前は俺を愛してる)の3ワードのセンテンスを例としましょう。オリジナルの論文では512次元のベクトルでしたが、今は説明のため、各ワードは16次元でembedding+PE されているとします。

まずは、16次元のワードを3次元のベクトルに落とし込みます。(3ワードの”3”とは無関係です。短縮次元はいくつでもいいのです。)。それぞれの生成行列を定義します。初期値はランダムです。

Query, Key, Valueの生成行列を学習することをSelf-attention層では目的とする。各ワードにかけ合わせ、それぞれの単語に対するquery, key, value を作る。16次元を3次元に変換する。

Step:2計算

上記処理を行ったあとは、ワードに対するそれぞれのquery, key, valueベクトルを用いて内積値の計算を実施します。まず、Youを例に取り、You, love, meのKeyベクトルとValueベクトルを使って結果zを取り出します。計算処理の内容については下記のとおりです。

self-attention層計算
Youのqueryベクトルに対して、You, Love, MeそれぞれのKeyベクトルと内積値を取る。現在次元数は16次元から3次元(繰り返すが単語数の3とは関係ないので注意)になったので、そのsqrt(現在の次元数3)で除算し、それとValueベクトルとかけ合わせ足し合わせる。なおSoftmaxの合計は必ず1(0.67+0.22+0.11=1)であるため、加重平均ともとれる。これで算出された値をz1ベクトルとする。これを、Love(q2), Me(q3)に対しても行いz2, z3も求める

これが、Self-attention層で行われている処理です。ただし、Transformerでは上記を複数のヘッドで実装することで高速化と精細化に成功しました。Multi-head attentionと言われる実装です。

Multi-head attentionの流れ

先程説明した処理を、複数のヘッドで行うのがMulti-head attentionという処理になります。先程の説明はsingle-head と呼べるかもしれません。今回は、8つのヘッドがあるとして説明していきます。

embedding+PE された次元数が512次元だとして、まず入力を8つのヘッドで分割するものとします。すると、一つの次元は64次元となります。分割の仕方は、前からぶつ切りに切りです。

split( [ ……. ] )=> [ ….], [ ….] ,[ ….] ,[ ….] ,[ ….] ,[ ….] ,[ ….] ,[ ….]

あとは、この64次元ごとに、上記のSelf-attentionで説明したStep2の処理を行います。Multi-head attentionを行う際には Step1の次元縮小処理は必要ありません。ただ、入力値ベクトルからkey, value, query ベクトル生成用の行列は作成する必要があるので、次元を縮小せずに同じ次元で出力するような処理は必要があります。つまり、pytrochで表せば、nn.Linear( 64, 64 ) のように同じ次元を指定しています 。

さて、Multi-head attentionで処理をした結果としてですが、最終的に8つのHeadから、8つの64次元の結果が出てきます。この結果を元の分けたとおりの順番で連結し元に戻してやります。(今回の例であれば512次元にもどす)

concat( [ ….], [ ….] ,[ ….] ,[ ….] ,[ ….] ,[ ….] ,[ ….] ,[ ….] )=> [ ……. ]

これだけです。Multihead-Attentionをやる理由は、並列に処理ができるようになるため高速化が期待できることもそうですが、結果として分割して処理をすると精度が上昇するという理由もあります。そのため、実装ではMulti-head Attentionを実装します。さて、今説明した内容をソースコードで示します。

class MultiHeadSelfAttention( nn.Module ):
    def __init__( self, dimention, num_head, dropout = 0.1):
        super().__init__()

        assert dimention % num_head == 0
        self.dim = dimention
        self.num_head = num_head 
        self.head_dim = self.dim // self.num_head

        self.q_linear = nn.Linear( self.dim, self.dim )
        self.k_linear = nn.Linear(self.dim,self.dim )
        self.v_linear = nn.Linear(self.dim,self.dim )        
        self.out = nn.Linear(self.dim,self.dim )    

        self.dropout = nn.Dropout( dropout )

        self.scale = torch.sqrt(torch.FloatTensor([self.head_dim]) )


    # q = [batch size, query len, hid dim]
    # k = [batch size, key len, hid dim]
    # v = [batch size, value len, hid dim]
    def foward( self, q, k, v, mask =None ):
        batch_size = q.size( 0 )

        Q = self.q_linear(q) # [batch size, query len, dimention]
        K = self.k_linear(k) 
        V = self.v_linear(v) 

        Q = Q.view(batch_size, -1, self.num_head, self.head_dim).permute(0, 2, 1, 3) # [batch size, n heads, query len, head dim]
        K = K.view(batch_size, -1, self.num_head, self.head_dim).permute(0, 2, 1, 3)
        V = V.view(batch_size, -1, self.num_head, self.head_dim).permute(0, 2, 1, 3)

        self.k_linear( k ).view( batch_size, -1, self.num_head, self.head_dim)

        energy = torch.matmul(Q, K.permute(0, 1, 3, 2)) / self.scale # [batch size, n heads, query len, key len]

        if mask is not None:
            energy = energy.masked_fill(mask == 0, -1e10)

        attention = torch.softmax( energy, dim = -1 ) # [batch size, n heads, query len, key len]

        x = torch.matmul(self.dropout(attention), V) # [batch size, n heads, query len, head dim]
        x = x.permute(0, 2, 1, 3).contiguous().view(batch_size, -1, self.dim) # [batch size, query len, dim]

        return self.out( x ), attention # self.out(x) == [batch size, query len, hid dim]

4.ノーマライゼーション層(+ residual の説明 )

標準化、正規化、規格化など、呼ばれるノーマライゼーションにより安定した学習が出来るようになります。中で実装している内容ですが、値を平均から引き、標準偏差で割るような操作をしてやります。平均が0で、分散が1になります。まさにノーマライゼーションですね。

言葉だとわかりにくいので、どういう処理をしているのか、ある人[6]がソースコードを書いていました。参考にしてみてください

class Norm(nn.Module):
    def __init__(self, dimention, eps = 1e-6):
        super().__init__()
            
        self.size = dimention        
        self.alpha = nn.Parameter(torch.ones(self.size))
        self.bias = nn.Parameter(torch.zeros(self.size))
        self.eps = eps
    def forward(self, x):
        norm = self.alpha * (x - x.mean(dim=-1, keepdim=True)) / (x.std(dim=-1, keepdim=True) + self.eps) + self.bias
        return norm

residual(残差処理) の説明

残差処理といいますが、Encoderを構築する際に、Multi header attention で出てきた出力値に、元々の入力Inputを足し合わせます。そしてNormalization処理をします。疑似コードであらわすと、単に次のような操作をしているだけです。

y = Normlization( MulitHeadderAttention( input ) + input )

これを残差処理といいます。下記のオリジナルの論文を見ると、Add&Norm内で入力値を持ち込んでいるので残差処理をしているのがわかります。

オリジナルの論文の画像。Add&Normの処理が残差処理として利用される。入力のinputと、attentionで出てきた出力を足しこみ平均化(標準化)処理を行っている

5.Feed Forward 層

Feed Fowardでは、2層の全結合層からなるニューラルネットワークを実装しています。また、Dropoutも定義することで過学習を抑制します。Dropoutに関しては、次の記事が明るいです。[14]

数式ではつぎのようなことを行っています。

FFN(x)=max(0,xW1+b1)W2+b2

図では次のとおりです。いえば、ただのニューラルネットワークです。

よく見られる2層のニューラルネットワーク。中間層のサイズを大きくして表現力を上げる作業をここでは行う。尚、Dropoutも実施する。

ソースコードで実際に表現すると、次のような単純な処理をしているだけです。

class FeedForward(nn.Module):
    def __init__(self, dimention, d_ff=2048, dropout = 0.1):
        super().__init__() 
        self.linear_1 = nn.Linear(dimention, d_ff)
        self.dropout = nn.Dropout(dropout)
        self.linear_2 = nn.Linear(d_ff, dimention)
    def forward(self, x):
        x = self.dropout(F.relu(self.linear_1(x)))
        x = self.linear_2(x)
        return x

ここまでで、Encoderで使われている全てのパーツの説明がおわりました!実はEncoderもDecoderもほとんど同じパーツを使います。唯一,Encoder-decoder attention層だけがちがうのですが、その解説を行います。

Decoder: Encoder-DecoderAttention層

改めてTransformerの全体図をみてみると、次のようになっています。

Transformer 全体図 [8]より。左のEncoder、右のDecoderとも多段で縦に接続されているが、説明上それぞれ一段構成になっている。

ブロックを見てみると殆どの解説が終わっている部分ばかりです。唯一Encoder-DecoderAttentionだけ説明をしていませんでした。ただ、Encoder-Decoder Attentionは次の画像がとてもわかり易いかと思います。

encoder で使われたValue, Keyを渡す。 デコーダーではquery を使う。

やっていることはSelf-attentionと一緒です。ただし、Encoder-Decoder層では、最後のEoncoderパートで出力された値を、Value, Keyベクトルを生成するための入力として渡します。Queryベクトルを作成するための入力に関しては、Decoder内部のself attentionでの出力値を使います。

内部で計算する処理は、Self-attention層の計算した方法(Multi-attentionで説明した計算方法)と全く同じロジックで計算するのです。これだけとなります。

なお、入力に対してまだ出現していないもので推測するのはおかしいというロジックの元、マスクをかけることがあります。

Transformer 全体像とEncoder, Decoderの実装

改めてTransformer全体像

さて、いままで説明してきたものを連結していくとTransformerの完成です。改めて全体像の画像を見てみます。

Transformer 全体像

上記の図でNxと書いてあることに気づきますが、これは複数個、縦につなげるということを意味しています。オリジナルの論文だと6段の構成となっています。

Encoderの実装

N段構成を担う、一つのEncoderブロックの実装例を以下に示します。

class EncoderBlock(nn.Module):
    def __init__(self, dimention, n_heads, dropout ):
        super().__init__()
        self.self_attention = MultiHeadSelfAttention( dimention, n_heads, dropout )
        self.self_attn_layer_norm = nn.LayerNorm(dimention)
        self.ff_layer_norm = nn.LayerNorm(dimention)
        self.feadforward = FeedForward(dimention)
        
        self.dropout_1 = nn.Dropout(dropout)                
        self.dropout_2 = nn.Dropout(dropout)                

    def forward( self, x, src_mask ):
        #x = [batch size, src len, dim]
        #src_mask = [batch size, 1, 1, src len] 
        new_x, _ = self.self_attention( x, x, x, src_mask)  
        new_x = self.self_attn_layer_norm(x + self.dropout_1(new_x)) #src_x = [batch size, src len, dim]
        out_x = self.feadforward(new_x)        
        out_x = self.ff_layer_norm(x + self.dropout_2(out_x)) #out_x = [batch size, src len, dim]
        return out_x

さて、ブロックができればいよいよエンコーダーです。

class Encoder(nn.Module):
    def __init__(self, vocab_size, dimention, Nx, n_heads, dropout = 0.1, max_word_len = 100):
        super().__init__()
        self.embed = nn.Embedding(vocab_size, dimention)
        self.pe = PositionalEncoder( max_word_len, dimention)        
        self.blocks = nn.ModuleList([ EncoderBlock( dimention,  n_heads, dropout ) for _ in range(Nx) ] )
        self.norm = Norm(dimention)

    #src = [batch size, src len]
    #src_mask = [batch size, 1, 1, src len]
    def forward(self, src, src_mask):
        x = self.embed(src)
        x = self.pe(x)        
        for encoder_block in self.blocks:
            x =encoder_block(x, src_mask) #src = [batch size, src len, hid dim]        
        return self.norm(x)

Encoder内のNxは何段のブロックを使うか、n_headsはmulti-head attentionのヘッダーのサイズを表しています。

Decoderの実装

続いて、デコーダーです。まずはDecoderを構成する1つのブロックのソースコードを記載します。

class DecoderBlock(nn.Module):
    def __init__(self, dimention,  n_heads, dropout ):                 
        super().__init__()        
        self.norm1 = nn.LayerNorm(dimention)
        self.norm2 = nn.LayerNorm(dimention)
        self.norm3 = nn.LayerNorm(dimention)
        self.dropout = nn.Dropout(dropout)

        self.attention_self = MultiHeadSelfAttention(dimention, n_heads, dropout)
        self.attention_encoder = MultiHeadSelfAttention(dimention, n_heads, dropout)
        self.feedforward =FeedForward(dimention)                                                                     

    #x = [batch size, target len, hid dim]
    #enc_src = [batch size, src len, hid dim]
    #target_mask = [batch size, 1, target len, target len]
    #src_mask = [batch size, 1, 1, src len]
    def forward(self, x, x_from_encoder, target_mask, src_mask):        
        _target, _ = self.attention_self(x, x, x, target_mask)
        x = self.norm1(x + self.dropout(_target))   #x = [batch size, target len, hid dim]                    
        _target, attention = self.attention_encoder(x, x_from_encoder, x_from_encoder, src_mask)         
        x = self.norm2(x + self.dropout(_target)) # target = [batch size, target len, hid dim]        
        _target = self.feedforward(x)        
        x = self.norm3(x + self.dropout(_target)) # target = [batch size, target len, hid dim], 
        return x, attention # attention = [ batch size, n heads, target len, src len ]         

上記のデコーダーブロックを使いデコーダーを実装します。

class Decoder(nn.Module):
    def __init__(self, vocab_size, dimention, n_layers, n_heads, dropout = 0.1, max_word_len = 100):
        super().__init__()
        self.embed_vocab = nn.Embedding( vocab_size, dimention ) # 
        self.pe = PositionalEncoder( max_word_len, dimention)        
        self.blocks = nn.ModuleList( [ DecoderBlock(dimention, n_heads  , dropout ) for _ in range(n_layers) ] )
        self.scale = torch.sqrt(torch.FloatTensor([dimention]))
        self.norm = Norm(dimention)

    #trg = [batch size, trg len]
    #enc_src = [batch size, src len, dimention]
    #trg_mask = [batch size, 1, trg len, target len]
    #src_mask = [batch size, 1, 1, src len]                
    def forward(self, target, x_from_encoder, trg_mask, src_mask):        
        x = self.embed_vocab( target )
        x = self.pe( x )
        for decoder_block in self.blocks:
            x, attention = decoder_block(x, x_from_encoder, trg_mask, src_mask) #target  = [batch size, trg len, hid dim], attention = [batch size, n heads, trg len, src len]
        output = self.norm(x) #output = [batch size, trg len, output dim]
        return output , attention    

ほとんどEncoderと変わらないような構成をしています。

Transformerの実装

いよいよTransformerの実装です。すでにEncoder, Decoderは実装しているので、これを利用するだけです。

class Transformer(nn.Module):
    def __init__(self, src_vocab, trg_vocab, d_model, N, heads):
        super().__init__()
        self.encoder = Encoder(src_vocab, d_model, N, heads )
        self.decoder = Decoder(trg_vocab, d_model, N, heads )
        self.out = nn.Linear(d_model, trg_vocab)
    def forward(self, src, target, src_mask, trg_mask):
        output_encoder = self.encoder(src, src_mask)
        output_decoder = self.decoder(target, output_encoder, trg_mask, src_mask )
        output = self.out(output_decoder)
        return output     

終わりに

今回はTransformerについてアルゴリズムをソースコード付きで解説しました。意外と単純な構成をしているんだと気づいたと思います。いまはやりのGPT-3,BERTなどの最先端の学習は、すべてTransformerの技術が基本になっています。まずは今回の記事でTransformerとはどういったものか感じていただけると幸いです。

参考文献

[1] https://jalammar.github.io/visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention/

[2] https://towardsdatascience.com/transformers-explained-visually-part-3-multi-head-attention-deep-dive-1c1ff1024853

[3] https://ai.googleblog.com/2017/08/transformer-novel-neural-network.html

[4] https://jalammar.github.io/illustrated-transformer/

[5] https://mchromiak.github.io/articles/2017/Sep/12/Transformer-Attention-is-all-you-need/#.XIWlzBNKjOR

[6]https://towardsdatascience.com/how-to-code-the-transformer-in-pytorch-24db27c8f9ec

[7]https://machinelearningmastery.com/a-gentle-introduction-to-positional-encoding-in-transformer-models-part-1/

[8]https://towardsdatascience.com/transformers-explained-visually-part-2-how-it-works-step-by-step-b49fa4a64f34

[9]https://towardsdatascience.com/how-to-code-the-transformer-in-pytorch-24db27c8f9ec

[10]https://pytorch.org/tutorials/beginner/nlp/word_embeddings_tutorial.html

[11]https://kazemnejad.com/blog/transformer_architecture_positional_encoding/

[12]https://medium.com/swlh/elegant-intuitions-behind-positional-encodings-dc48b4a4a5d1

[13]https://data-science-blog.com/blog/2021/04/07/multi-head-attention-mechanism/

[14]https://medium.com/axinc/dropout%E3%81%AB%E3%82%88%E3%82%8B%E9%81%8E%E5%AD%A6%E7%BF%92%E3%81%AE%E6%8A%91%E5%88%B6-be5b9bba7e89

[15] https://pytorch.org/docs/stable/generated/torch.nn.LayerNorm.html

[16]https://kikaben.com/transformers-encoder-decoder/

[17]https://medium.com/@smitasasindran/12-attention-mechanisms-multihead-attention-958041a35553

Sequence To Sequence ( Seq2Seq )

Sequence to sequence ( Seq2Seq )の技術を紹介します。

機械学習界隈ではブレイクスルーな技術としてGANVAEが話題となりました。今回解説するSeq2Seqもブレイクスルーといわれる技術の1つです。

近年、劇的に日本語の翻訳技術が向上しました。その技術の背景にはSeq2Seqが使われています。Seq2Seqの得意分野は翻訳もそうですが、自動字幕技術、あるいはチャットボットなどのQuestions answeringの技術であり、連続値などのシーケンシャルなデータの取扱が得意です。

Seq2SeqはGoogleにより2014年に発表されました。Googleは2015年頃の論文でSpeech recognition[1]やビデオ字幕[2]で劇的に精度が向上したと発表しています。

今回はそんなSeq2Seqの技術を解説し、最後にPyTorchでの実装例を紹介します。

Sequence to Sequence の構造

Seq2Seqは2つのパートに別れます。EncoderとDecoderです。形的にはAutoEncoder(例えばVAE)に少し似ています。ただ、中の実装方式がまるで違います。

Seq2SeqはRNNを利用しているため時系列データに強いという所が特徴です。 そのため翻訳や音声認識の分野で力を発揮しているのです。

Encoder&Decoderの2つのパートに分かれる。入力xをいれ、出力yを受け取る。SOSはStart of String, EOSはEnd of stringを表す。Encoder部分で出力されるものは無視される。Decoder部分では、入力として解答をいれ、出力としてもその回答が出るようにする。2つのパートに分かれるが、厳密には真ん中のContextを含めた3パートであるのが正しい。

Encoder、ならびにDecoderの内部では横矢印でデータをそれぞれ前段のデータを数珠つなぎに渡しています。RNNの特徴で、学習時に前段の特徴をわたすことにより時系列データに強くなるのです。

Seq2Seqを理解するに辺りRNNの概念の理解が必要です。本ブログのRNNの記事の概念の部分を読んで把握しておいてください。

それではEncoderとDecoderを実際にどういう仕組みや役割を担っているのか見ていきます。

EncoderとDecoder

Seq2Seqで作られた翻訳エンジンを例にEncoderとDecoderの役割を解説します。翻訳とは「こいつは犬。」という入力に対し、「 This is a dog.」と解答が得ることと定義します。

Encoder部は、日本語で「こいつは犬。」を入力するパートになります。

Decoder部は 、「This is a dog.」という入力、並びにEncoderから出る横矢印の隠れ層のデータ(Context)を受け取って、入力と同じ「This is a dog.」という出力を得るパートです。やや複雑ですが、下の図を見て、理解をしてください。

Encoderでは日本語を入力として受け取っている。入力は単語単位で行う。単語数は素子数を上回ることは出来ない。Decoderでは入力として英単語、ならびにEncoderからのContextを受け取る。出力は1段ずつ遅れて出力するモデルを考える。

上の図を見ると面白いことに気づきます。「こいつ は 犬」と3つ単語に対し、「This is a dog」と4つの単語が出力されます。 Seq2Seqの特徴ですが入力数と出力数が一致していなくてもいいのがユニークなところです。上の図の説明ではEncoder&Decoderともに5つの素子で構成しています。実際には最大の文章の単語数以上の素子を用意しておくことになります。

Decoder部分に注目してみます。入力値として英語の答えを入力し、出力値として、一弾ずつずれて同じものを期待するように設計しています。

<SOS>,< EOS>はStart of string, End of stringの略ですが、学習時にはこれをつけて学習していきます。これを伝えることで、文の始まりと終わりを伝えるのです。

Encoderの出力値については捨てられます。ただし、使うことに依り精度を上げる方法(+Attention法)もあります。アルゴリズムの選択により使ったり使わなかったりするのです。

さて、DecoderとEncoderをつなげるのは、Encoderで学習したHiddenベクトル(Context)となります。

以上が簡単な説明になります。世界を圧巻したSeq2Seqですが意外と簡単な構造をしていたんだな、と思うと思います。それではいよいよPyTorchで実装していきます。

実装

今回はPytorchの公式のSeq2Seqを参考にソースコード解説をします。本家はやや説明に冗長なコードがありますので、Seq2seqを理解するためだけのコードにしました。

下準備(学習データ)

学習には次のファイルを使いましょう。

実装する上では学習データを用意しないと学習できません。残念ながらPyTorchでは標準で日本語データサポートしていないので、他社サイトからデータを取得します。

今回はこちらのサイトからデータを取得しました。流れとしては、そして日本語と英語の2つのファイルに分けました。日本語は英語のようにスペースで分けられていないので、分かち書き(形態素解析)によって分割しました。日本語には半角全角といった表記ゆれもあるのでそうしたものを正規化処理します。具体的にはそれぞれMecab, unicodedata.normalizeなどを使うのですがその辺りは今回のseq2seq技術と全く関係ないのでここでは説明しません。

ファイルの1行1行の日本語と英語が1対1対応しています。

他の言語でもテストしてみたい場合は、こうした学習データを作り、独自に学習させてみてください。

また、import文と、言語の処理クラスLangをインポートします。Lang クラスではwordをindex化したりするクラスです。

import random

import torch
import torch.nn as nn
from torch import optim
import torch.nn.functional as F

SOS_token = 0
EOS_token = 1

device = "cuda" # torch.device("cuda" if torch.cuda.is_available() else "cpu")                                                                                                                                                                                                                                                                                                                                                                                                                                                                  

class Lang:
    def __init__( self, filename ):
        self.filename = filename
        self.word2index = {}
        self.word2count = {}
        self.sentences = []
        self.index2word = { 0: "SOS", 1: "EOS" }
        self.n_words = 2  # Count SOS and EOS                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   

        with open( self.filename ) as fd:
            for i, line in enumerate( fd.readlines() ):
                line = line.strip()
                self.sentences.append( line )
        self.allow_list = [ True ] * len( self.sentences )
        self.target_sentences = self.sentences[ :: ]

    def get_sentences( self ):
        return self.sentences[ :: ]

    def get_sentence( self, index ):
        return self.sentences[ index ]

    def choice( self ):
        while True:
            index = random.randint( 0, len( self.allow_list ) - 1 )
            if self.allow_list[ index ]:
                break
        return self.sentences[ index ], index

    def get_allow_list( self, max_length ):
        allow_list = []
        for sentence in self.sentences:
            if len( sentence.split() ) < max_length:
                allow_list.append( True )
            else:
                allow_list.append( False )
        return allow_list

    def load_file( self, allow_list = [] ):
        if allow_list:
            self.allow_list = [x and y for (x,y) in zip( self.allow_list, allow_list ) ]
        self.target_sentences = []
        for i, sentence in enumerate( self.sentences ):
            if self.allow_list[ i ]:
                self.addSentence( sentence )
                self.target_sentences.append( sentence )

    def addSentence( self, sentence ):
        for word in sentence.split():
            self.addWord(word)


    def addWord( self, word ):
        if word not in self.word2index:
            self.word2index[ word ] = self.n_words
            self.word2count[ word ] = 1
            self.index2word[ self.n_words ] = word
            self.n_words += 1
        else:
            self.word2count[word] += 1
def tensorFromSentence( lang, sentence ):
    indexes = [ lang.word2index[ word ] for word in sentence.split(' ') ]
    indexes.append( EOS_token )
    return torch.tensor( indexes, dtype=torch.long ).to( device ).view(-1, 1)

def tensorsFromPair( input_lang, output_lang ):
    input_sentence, index = input_lang.choice()
    output_sentence       = output_lang.get_sentence( index )

    input_tensor  = tensorFromSentence( input_lang, input_sentence )
    output_tensor = tensorFromSentence( output_lang, output_sentence )
    return (input_tensor, output_tensor)

Encoder

Encoderの実装は次のとおりです。

class Encoder( nn.Module ):
    def __init__( self, input_size, embedding_size, hidden_size ):
        super().__init__()
        self.hidden_size = hidden_size
        # 単語をベクトル化する。1単語はembedding_sie次元のベクトルとなる                                                                                                                                                          
        self.embedding   = nn.Embedding( input_size, embedding_size )
        # GRUに依る実装.                                                                                                                                                                  
        self.gru         = nn.GRU( embedding_size, hidden_size )

    def initHidden( self ):
        return torch.zeros( 1, 1, self.hidden_size ).to( device )

    def forward( self, _input, hidden ):
        # 単語のベクトル化                                                                                                                                                                                                        
        embedded        = self.embedding( _input ).view( 1, 1, -1 )
        # ベクトル化したデータをGRUに噛ませる。通常のSeq2Seqでは出力outは使われることはない。                                                                                                                                     
        # ただしSeq2Seq + Attentionをする場合にはoutの値を使うことになるので、リターンする                                                                                                                                        
        out, new_hidden = self.gru( embedded, hidden )
        return out, new_hidden

Encoderでは文字列をEmbeddingします。Embeddingとは単語をベクトル化することです。例えばDogを5次元にEmbeddingするとするとDog–>[0.9, 0.5 0.4, 0.7, 0.1] のようにすることを意味します。

Embeddingは実はword2vecを用いたほうが精度が良いようですが、とりあえず今はSeq2Seqの実装とは関係ないので標準のライブラリを使います。精度を上げたい人はこの部分を改良してみても面白いでしょう。

Embeddingされた単語をRNNのネットワークに入れるのですが、PyTorchではRNN系として、nn.LTSM, nn.RNN, nn.GRUというものが既にあり、自分で数珠つなぎのRNN素子を定義してネットワークを書く必要はありません。RNNモジュールの入力次元はEmbeddingする次元になります。

実装ではGRUかLSTMで世の中では良く取り沙汰されています。今回はPyTorchの公式ドキュメントでGRUであったのと、日本語のドキュメントサイトでLSTMが多かったのでGRUで説明します。LSTMで実装したいなどあれば適時、ソースコードを書き換えてみてください。(nn.LSTMは出力がGRUとことなるので注意が必要です。view関数などを使って出力数を変更する必要もあります。)

Decoder

Decoderの実装は次のとおりです。

class Decoder( nn.Module ):
    def __init__( self, hidden_size, embedding_size, output_size ):
        super().__init__()
        self.hidden_size = hidden_size
        # 単語をベクトル化する。1単語はembedding_sie次元のベクトルとなる                                                                                                                                                          
        self.embedding   = nn.Embedding( output_size, embedding_size )
        # GRUによる実装(RNN素子の一種)                                                                                                                                                                                          
        self.gru         = nn.GRU( embedding_size, hidden_size )
        # 全結合して1層のネットワークにする                                                                                                                                                                                      
        self.linear         = nn.Linear( hidden_size, output_size )
        # softmaxのLogバージョン。dim=1で行方向を確率変換する(dim=0で列方向となる)                                                                                                                                                
        self.softmax     = nn.LogSoftmax( dim = 1 )

    def forward( self, _input, hidden ):
        # 単語のベクトル化。GRUの入力に合わせ三次元テンソルにして渡す。                                                                                                                                                           
        embedded           = self.embedding( _input ).view( 1, 1, -1 )
        # relu活性化関数に突っ込む( 3次元のテンソル)                                                                                                                                                                             
        relu_embedded      = F.relu( embedded )
        # GRU関数( 入力は3次元のテンソル )                                                                                                                                                                                       
        gru_output, hidden = self.gru( relu_embedded, hidden )
        # softmax関数の適用。outputは3次元のテンソルなので2次元のテンソルを渡す                                                                                                                                                 
        result             = self.softmax( self.linear( gru_output[ 0 ] ) )
        return result, hidden

    def initHidden( self ):
        return torch.zeros( 1, 1, self.hidden_size ).to( device )

ほとんどEncoderと一緒です。ただRelu活性関数を適用したり、最後に全結合してSoftmax関数を噛ませているところに違いがあります。また、Decoderでは入力値として、前段のEncoderからのHiddenベクトルをもらうところが違いがあります。

メイン関数

EncoderとDecoderを用いたメイン関数は次のとおりです。

def main():
    n_iters       = 75000
    learning_rate = 0.01 * 0.8
    embedding_size = 256
    hidden_size   = 256
    max_length    = 30

    input_lang  = Lang( 'jpn.txt' )
    output_lang = Lang( 'eng.txt')
    # 英単語数がmax_lengthより多い場合は計算しない。(時間がかかるため。)                                                                                                                                                                                                                                                                                                                                  
    allow_list = [x and y for (x,y) in zip( input_lang.get_allow_list( max_length ), output_lang.get_allow_list( max_length ) ) ]
    # allow_listに従って、英語、日本語のファイルをロードする                                                                                                                                                                                                                                                                                                                                                
    input_lang.load_file( allow_list )
    output_lang.load_file( allow_list )
    # Encoder & Decoderの定義                                                                                                                                                                                                                                                                                                                                                                               
    encoder           = Encoder( input_lang.n_words, embedding_size, hidden_size ).to( device )
    decoder           = Decoder( hidden_size, embedding_size, output_lang.n_words ).to( device )
    # Optimizerの設定                                                                                                                                                                                                                                                                                                                                                                                       
    encoder_optimizer = optim.SGD( encoder.parameters(), lr=learning_rate )
    decoder_optimizer = optim.SGD( decoder.parameters(), lr=learning_rate )
    # 学習用のペアデータの作成. He is a dog, 彼は犬だ みたいなペアをエポック数分用意する                                                                                                                                                                                                                                                                                                                    
    training_pairs = [ tensorsFromPair( input_lang, output_lang ) for i in range( n_iters ) ]
    # LOSS関数                                                                                                                                                                                                                                                                                                                                                                                              
    criterion      = nn.NLLLoss()

    for epoch in range( 1, n_iters + 1):
        # 学習用のペア単語の取り出し。                                                                                                                                                                                                                                                                                                                                                                      
        input_tensor, output_tensor = training_pairs[ epoch - 1 ]
        #初期化                                                                                                                                                                                                                                                                                                                                                                                             
        encoder_hidden              = encoder.initHidden()
        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()
        input_length  = input_tensor.size(0)
        output_length = output_tensor.size(0)

        # Encoder phese                                                                                                                                                                                                                                                                                                                                                                                     
        for i in range( input_length ):
            encoder_output, encoder_hidden = encoder( input_tensor[ i ], encoder_hidden )

        # Decoder phese                                                                                                                                                                                                                                                                                                                                                                                     
        loss = 0
        decoder_input  = torch.tensor( [ [ SOS_token ] ] ).to( device )
        decoder_hidden = encoder_hidden
        for i in range( output_length ):
            decoder_output, decoder_hidden = decoder( decoder_input, decoder_hidden )
            # 次の入力野取り出し                                                                                                                                                                                                                                                                                                                                                                            
            decoder_input = output_tensor[ i ]
            # 学習では一定の確率(ここでは50%)で、自身が前に出力した単語を次の入力とする。                                                                                                                                                                                                                                                                                                              
            if random.random() < 0.5:
                # 確率が最も高い単語を抽出                                                                                                                                                                                                                                                                                                                                                                  
                topv, topi                     = decoder_output.topk( 1 )
                # 確率が一番高かった単語を次段の入力とする                                                                                                                                                                                                                                                                                                                                                  
                decoder_input                  = topi.squeeze().detach()

            # Loss関数                                                                                                                                                                                                                                                                                                                                                                                      
            loss += criterion( decoder_output, output_tensor[ i ] )
            # EOSに当たった場合は終わる。                                                                                                                                                                                                                                                                                                                                                                   
            if decoder_input.item() == EOS_token: break
        loss.backward()
        encoder_optimizer.step()
        decoder_optimizer.step()
        # 進捗状況の表示                                                                                                                                                                                                                                                                                                                                                                                    
        if epoch % 50 == 0:
            print( "[epoch num %d (%d)] [ loss: %f]" % ( epoch, n_iters, loss.item() / output_length ) )

流れに関してはコメントアウトにて記載しました。

実装をする上で、言語を管理するLangクラスを定義しました。内容はSeq2seqの技術と関係ないので割愛しますが、Langクラスについては、Githubにあるmain.pyを参考にしてください。

評価関数

学習がきちんとできたか、実際確かめる評価関数は次のとおりです。

def evaluate( sentence, max_length ):
    input_lang  = Lang( 'jpn.txt')
    output_lang = Lang( 'eng.txt' )
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
    allow_list = [x and y for (x,y) in zip( input_lang.get_allow_list( max_length ), output_lang.get_allow_list( max_length ) ) ]

    input_lang.load_file( allow_list )
    output_lang.load_file( allow_list )

    hidden_size = 256
    embedding_size =256
    encoder = Encoder( input_lang.n_words, embedding_size, hidden_size ).to( device )
    decoder = Decoder( hidden_size, embedding_size, output_lang.n_words ).to( device )


    enfile = "OUTPUT_FILE_FROM_ENCODER"
    defile = "OUTPUT_FILE_FROM_DECODER"
    encoder.load_state_dict( torch.load( enfile ) )
    decoder.load_state_dict( torch.load( defile ) )

    with torch.no_grad():
        input_tensor   = tensorFromSentence(input_lang, sentence)
        input_length   = input_tensor.size()[0]
        encoder_hidden = encoder.initHidden()

        for ei in range(input_length):
            encoder_output, encoder_hidden = encoder(input_tensor[ei], encoder_hidden)

        decoder_input      = torch.tensor([[SOS_token]], device=device)  # SOS                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
        decoder_hidden     = encoder_hidden
        decoded_words      = []
        decoder_attentions = torch.zeros(max_length, max_length)

        for di in range(max_length):
            decoder_output, decoder_hidden = decoder( decoder_input, decoder_hidden )

            topv, topi = decoder_output.data.topk(1)
            if topi.item() == EOS_token:
                decoded_words.append('<EOS>')
                break
            else:
                decoded_words.append(output_lang.index2word[topi.item()])

            decoder_input = topi.squeeze().detach()
        return decoded_words, decoder_attentions[:di + 1]

if __name__ == '__main__':
    import MeCab
    import unicodedata
    wakati = MeCab.Tagger("-Owakati")
    sentence = 'とても悲しいです.'
    sentence = unicodedata.normalize( "NFKC", sentence.strip() )
    a=wakati.parse( sentence.strip() ).split()
    ret =" ".join( a )

    print(evaluate( ret, 30 ) )

enfile, defileは学習時したデータのPATHを記載します。

注意ですが、hidden_sizeとmax_lengthは学習時と同じ値を使うようにします。

encoder.load_state_dict(torch.load(FILE_PATH))により、実際に学習したデータをロードしています。

結果

60をmax_length、epoch数を150,000として学習させた結果は次の通りです。学習には40分程かかります。(GPUを利用した場合)

海外 に 旅行 に 行き たい .
([‘i’, ‘want’, ‘to’, ‘go’, ‘to’, ‘the’, ‘trip’, ‘.’, ”],

この 映画 は 面白い です か ?
([‘how’, ‘movie’, ‘is’, ‘this’, ‘movie’, ‘?’, ”]

これ は 料理 です .
([‘this’, ‘is’, ‘a’, ‘good’, ‘cook’, ‘.’, ”]

この 机 は 私 の 一番 の お気に入り です .
([‘this’, ‘is’, ‘is’, ‘most’, ‘of’, ‘mine’, ‘.’, ”],

彼 は とても いい 人 です .
([‘he’, ‘is’, ‘a’, ‘good’, ‘person’, ‘.’, ”]

さて、この結果考察をどう思うでしょうか。たかだか40分の学習時間、かつ愚直なデータでこの精度までいきました。正直驚きです。機会翻訳の分野で一生懸命やっていた人は更に驚くのではないでしょうか?

色々と問題があるものの、学習数を多くする、登録単語数を増やす、同じ学習データを何度も流し込む、など色々なアプローチで、明らかな文法ミスに対してペナルティを高くするなどしていくと劇的な変化が見られるのではないかと思います。又学習速度に関してもバッチ化することで高速化が見込めます。

興味がある人は是非トライしてみてください。多くのデータサイエンティスト、或いは機械学習のエンジニアがやっていく作業がこういう泥臭い作業になっていきます。

最後に

今回のソースコードはgithubにあげてあります。[Seq2Seq Github]

Githubでは、本稿では取り上げていない+attention法も実装してあります。

Attention法とは長文になると精度が悪くなるという弱点を補強したアルゴリズムです。そのため、短文で10単語くらいの簡単なものに関しては精度が劇的に上がることはありません。Attentionに関して、詳しくは次のTransformerもご参照ください。トランスフォーマーは、Seq2Seqの問題点を解決し、2022年現状で最も高い精度を誇る機械学習方法となっています。(GPT-3, BERTなどの元になっているやつです)

Seq2Seqでは、EncoderやDecoderを多層にしたり、Bidirectionalにしたり、GRUの代わりにLSTMを使ったり、というような色々な工夫があります。それぞれの方法でどれが良いかについてはここで述べられています[3]

実際に実装してみて、どれが精度が良いのかなど試してみると面白いと思いでしょう。

参考文献

  1. https://www.isca-speech.org/archive/Interspeech_2017/pdfs/0233.PDF
  2. https://arxiv.org/pdf/1505.00487.pdf
  3. https://arxiv.org/pdf/1908.04332.pdf
  4. https://towardsdatascience.com/understanding-encoder-decoder-sequence-to-sequence-model-679e04af4346
  5. https://pytorch.org/tutorials/intermediate/seq2seq_translation_tutorial.html

おすすめの記事

Variational AutoEncoder( VAE )

近年、ディープラーニング業界、はたまた画像処理業界ではGANとVAEの2つの技術で話題が持ちきりとなりました。GANについては前回解説しました。今回はそんなVAEのアルゴリズムについて解説をしていきます。GAN同様に最後にPyTorchによる実装例を紹介していきます。

VAEとは

VAEは情報を圧縮して圧縮した情報から元の情報に戻す、というような仕組みをもった、AE(Auto encoder)と言われるものの一種です。AEはただ単にデータの圧縮と再構築をするだけでしたが、VAEは確率モデルの理論を導入しています。VAEは確率分布を使ったモデルということは、未知のデータを確率的に作成できることになります。VAEはGenerative model(生成モデル)と言われています。トレーニングデータからProbability Density Function (PDF)を推定するモデルであるためす。 

GANと違って何ができるのか

VAEの一つの特徴は次元削減です。次元削減といえばPCASVDなどを思い浮かべるかもしれません。VAEは同様にLatent space(潜在空間)に次元圧縮し、またそこから復元するということをしています。

PCAのように圧縮した次元において直行している必要はありません。この辺も普通の次元圧縮とのアプローチとは異なっています。

赤はAEの軸。青はPCAの軸。PCAは軸同士は直行しているが、AEに関しては必ずしも直行している必要はない。

AEとVAEは違いがあまり無く感じますが、前述の通り確率を用いたところに違いがあります。AEは点(Single point)としてデータを潜在空間にマッピングしていましたが、VAEでは潜在空間に落とし込むときにガウス分布に従って落とし込ませます。デコーダーはその点を拾ってデコードするため、見方を変えれば、確率的にサンプリングしてzを拾っている、ということになるのです。このzをデコードして元の入力に近づけるようにするのです。

さて、VAEは確率モデリングと言われます。確率モデリングとは、例えば何かのデータxが分布していたとして、その分布を確率で表現するというモデルです。あるデータxが正規分布に従っていたとしたら、データxの確率分布は正規分布となり、P(X)=正規分布( μ, σ )と言う感じで表現します。P(X)と書くと思わずPは関数と勘違いするかもしれませんが、確率を表すものなので注意が必要です。

画像処理でVAEの例

VAEを画像処理に適用した場合は連続画像を取り扱うことができる、というのは聞いたことがあると思います。それはなぜかというと、エンコードした空間e(X)が確率を用いた分布で表されているためです。

パラパラマンガを想像してみてください。あるフレームと、その次のフレームがある時、2枚のフレーム同士はエンコードした空間において、近いところにあるはずです。(近いところにあるようにするのがVAEの目的であるためです)。この2枚のフレームの中間の画像は、エンコードした空間においてどこにあるのでしょうか?それは同じ空間内のちょうど中間のところにある確率が高いと推測できます。つまりそこの情報をデコードすれば、中間画像が生成されそうです。VAEではこうした事ができるため、連続画像に強いと言われています。

数式

VAEはデータを確率モデル化をすることを目標とします。データをX、その確率分布をP(X)と定義すれば、P(X)を見つけること、すなわちP(X)の最大化がVAEの目的です。そしてP(X)に関しては次のように表現することができます。[1]

P(X) = \int P(X \vert z) P(z) dz = \int P(X,z)

上記の式はXが入力画像と考え、その画像を表現する潜在ベクトルzを少なくとも1つ見つけたいという意味があります。

また、事前分布P(z)から\{z_i\}_{i=1}^nをサンプルした際に、P(X)を次のように近似できます。

P(X) \approx \frac{1}{n}\sum_{i=1}^n P(x|z_i)

P(X)を求めればいいのですが、一筋縄には行きません。それはXが高次元であることもそうですが、その確率を求めるのにはすべてのzの空間を舐め回すような非常にたくさんのサンプリングが必要なだけでなく、組み合わせをチェックするような処理が必要となり、総当りでP(X)を求めることは現実的ではありません。

そのため、P(X)を求めるには別のアプローチを考えます。もし事後確率のP(z|X)がもとめられれば、p(x|X)=\int P(x|z)p(z|X) dz より未知の画像xを作り出すような確率分布を求めることができそうです。そうなると 事後確率のP(z|X)を求めるだけとなり、簡単に思えますが事後確率はベイズの定理により次の式になります。

P(z|X) = P(X|z)\cfrac{P(z)}{ P(X)}

よく見るとあの厄介なP(X)が分母にあります。やはり求めることができません。

ここで、諦めずに別のアプローチで P(z|X) を求めていくようにしていきます。

P(z|X) を求める

P(z|X)を求めるにあたり少し工夫します。ここでVAEの名前の由来ともなっていますが、 Variational Inference(VI) という手法を使います。P(z|X)を推定するためにQ(z|X)という分布を考えます。Q(z|X)はP(z|X)の近似で、ガウス分布などの簡単な分布関数の組み合わせによりP(z|X)を近似します。

関数の組み合わせにより、近似していく。青の点線へ緑の分布関数で近似していく様子。 https://towardsdatascience.com/bayesian-inference-problem-mcmc-and-variational-inference-25a8aa9bce29

どれだけ似ているのか、という指標については類似尺度としてKLダイバージェンスを使います。同じ分布であれば0に、異なれば値が大きくなっていくような関数です。KLダイバージェンス次のように書くことが出来ます。

KL(P||Q) = E_{x \sim P(x)} log \cfrac{P(x)}{Q(x)}=\int_{-\infty}^{\infty}P(x)\ln \cfrac{P(x)}{Q(x)}dx

ここから式が多く出てくるので、見やすくするために単純にP(z|X )をP, Q( z|X)をQと表示します。Qの近似は次のようにかくことができます。

\begin{aligned} D_{KL}[Q\Vert P] &= \sum_z Q \log (\cfrac{Q}{P}) \\ &=E [ \log (\cfrac{Q}{P}) ] \\ &= E[\log Q - \log P] \end{aligned}

最後はlogの変換公式により、割り算を引き算にしただけです。さてP である P(z|X ) は P(z|X) = P(X|z)P(z) / P(X) のように表現できたので、先の式に当てはめてみましょう。

\begin{aligned} D_{KL}[Q\Vert P] &= E[\log Q - (\log (P(X \vert z) \cfrac{P(z)}{P(X)})] \\ &= E[\log Q- \log P(X \vert z) - \log P(z) + \log P(X)] \end{aligned}

ここでzに関する期待値でくくっている項に注目するとP(X)があります。P(X)はzに関係ありません。そのため括弧の外に出すことができます。

\begin{aligned} D_{KL}[Q\Vert P] &= E[\log Q- \log P(X \vert z) - \log P(z)] + \log P(X) \\ D_{KL}[Q\Vert P] - \log P(X) &= E[\log Q- \log P(X \vert z) - \log P(z)] \end{aligned}

ここからがトリッキーなのですが、右辺に注目するともう一つのKLダイバージェンスを見つけることができます。先の式を両辺にマイナス倍します。

\begin{aligned} \log P(X) - D_{KL}[Q\Vert P] &= E[-\log Q + \log P(X \vert z) + \log P(z)] \\ &= E[\log P(X \vert z) -( \log Q - \log P(z))] \\ &= E[\log P(X \vert z)] -E[( \log Q - \log P(z))] \\ &= E[\log P(X \vert z)]- D_{KL}[Q\Vert P(z) ] \end{aligned}

なんとPとQを近づけようとした結果、本来の目的であったP(X)に関する式がでてきてしまいました。PとQを縮約して記載していたので、正しく展開してかいてみます。

\log P(X) - D_{KL}[Q(z \vert X) \Vert P(z \vert X)] = E[\log P(X \vert z)] - D_{KL}[Q(z \vert X) \Vert P(z)]

本当の目的はP(X)の最大化でした。結局ですが、これを最終的なVAEの目的の関数として定義することになります。

最終的に得た式は非常に興味深い構造になっています。

  1. Q(z|X) はデータXを受取り、潜在空間にzを投影
  2. zは潜在変数
  3. P(X|z)は潜在変数zからデータ生成

つまりQ(z|X)はエンコーダ、zは潜在変数(エンコードされたデータ),そしてP(X|z)はデコーダです。まさにオートエンコーダーそのものとなりました。

算出された式を観察して、左辺と右辺がありますが、左辺にあるlog(p)最大化することが目的でした。そのためには右辺を最大化していくこと、つまり E[\log P(X \vert z)] を大きくして D_{KL}[Q(z \vert X) \Vert P(z)]を小さくしていくことで、左辺は大きくなっていきます。

そのため、今度は目的を右辺の最大化に絞っていきます。

右辺を最大化する。

右辺には以下の2つの式があります。

  1. E[\log P(X \vert z)]
  2. D_{KL}[Q(z \vert X) \Vert P(z)]

右辺=数式1-数式2、ですので数式1を最大化、数式2は最小化していけば、右辺は大きくなり目的が達成されます。

まず 数式 1についてですが、よく見ると潜在変数zを受取りXを生成する教師つきの学習そのものです。ですので、学習によりなんとかなりそうです。

さて、厄介なのが 数式 2です。ここで一つの仮定を起きます。P(z)は正規分布N(0, 1)と仮定するのです。そして、Xからzを生成する分布もパラメータ\mu(X), \sigma(X)付きの正規分布となります。 平均と分散はXを中心としたという意味です。そして、KLダイバージェンスは次のように表されます。

D_{KL}[N(\mu(X), \Sigma(X)) \Vert N(0, 1)] = \frac{1}{2} \, \left( \textrm{tr}(\Sigma(X)) + \mu(X)^T\mu(X) - k - \log \, \det(\Sigma(X)) \right)

kはガウシアンの次元数、traceは対角要素の和を表します。そして、detは対角要素の積\det \left({\mathbf A}\right) = \prod_{i \mathop = 1}^n a_{ii}です。

導出に関しては、ここでは重要でないため割愛しますが最終的には次のようになります。(導出に関して興味ある方は[2]を参照してください。)

D_{KL}[N(\mu(X), \Sigma(X)) \Vert N(0, 1)] = \frac{1}{2} \sum_k \left( \exp(\Sigma(X)) + \mu^2(X) - 1 - \Sigma(X) \right)

この項目を実装するときにはロス関数の中で利用します。実際との差分を計算するためですね。

全ての要素の説明ができました。あとはやるだけです。

実装

エンコーダーとデコーダーの実装は次のとおりです。

エンコーダー

class Encoder( nn.Module ):
    def __init__( self ):
        super().__init__()
	self.common = nn.Sequential(
            nn.Linear( 784, 400 ),
            nn.ReLU(),
            )
	self.model1 = nn.Sequential(
            self.common,
            nn.Linear( 400, 20 )
            )
        self.model2 = nn.Sequential(
            self.common,
            nn.Linear( 400, 20 )
            )
    def forward( self, img ):
	img_flat = img.view( img.size( 0 ), -1 )
        return self.model1( img_flat ), self.model2( img_flat )

デコーダー

class Decoder( nn.Module ):
    def __init__( self ):
	super().__init__()
	self.model = nn.Sequential(
            nn.Linear( 20, 400 ),
            nn.ReLU(),
            nn.Linear( 400, 784 ),
            nn.Sigmoid(),
            )
    def forward( self, z ):
        return self.model( z )

エンコーダーとデコーダーを用いいたVAEを次のように実装していきます。

VAE

class VAE( nn.Module ):
    def __init__( self ):
        super().__init__()
        self.encoder = Encoder()
	self.decoder = Decoder()

    def _reparameterization_trick( self, mu, logvar ):
        std = torch.exp( 0.5 * logvar )
        eps = torch.randn_like( std )
        return mu + eps * std

    def forward( self, _input ):
        mu, sigma = self.encoder( _input )
	z         = self._reparameterization_trick( mu, sigma )
        return self.decoder( z ), mu, sigma

説明ではzの取得は正規分布でのサンプリングを仮定しました。ところが実際にサンプリングするとバックプロパゲーションが出来ないため学習が出来ません。そこでreparameterization trickというのを用いいます。zを次の式で近似します。

z = \mu(X) + \Sigma^{\frac{1}{2}}(X) \, \epsilon

where, \epsilon \sim N(0, 1)

左図はZを表現するために本来のサンプリングで実装した図。左ではバックプロパゲーションができない。そのため、足し算と掛け算に依る表現に正規分布に従うノイズの足しこみを行い誤差伝搬を可能にする。[3]

最後に損失関数とVAEを使ったコードは次のとおりです。

# Kingma and Welling. Auto-Encoding Variational Bayes. ICLR, 2014                                                                                                                                                                 
# 入力画像をどのくらい正確に復元できたか?                                                                                                                                                                                        
def VAE_LOSS( recon_x, x, mu, logvar ):
    # 数式では対数尤度の最大化だが交差エントロピーlossの最小化と等価                                                                                                                                                              
    BCE = F.binary_cross_entropy(recon_x, x.view(-1, 784), size_average=False)
    # 潜在空間zに対する正則化項. # P(z|x) が N(0, I)に近くなる(KL-distanceが小さくなる)ようにする                                                                                                                               
    KLD = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    return BCE + KLD

def main():
    epoch_size = 50
    vae = VAE()
    vae.cuda()
    Tensor = torch.cuda.FloatTensor
    dataloader=get_dataloader()
    optimizer = torch.optim.Adam( vae.parameters(), lr=1e-3 )

    for epoch in range( epoch_size ):
        for i, ( imgs, _ ) in enumerate(dataloader):
            optimizer.zero_grad()
            real_images          = Variable( imgs.type( Tensor ) )
            gen_imgs, mu, logvar = vae( real_images )
            loss                 = VAE_LOSS( gen_imgs, real_images, mu, logvar ).cuda()
            loss.backward()
            optimizer.step()

VAE_LOSSでは得た画像が目的とした式と同じになるような差分を計算しています。

長くなりましたがこれがVAEの全貌です。今回のソースコードはGithubにあげてあります。

https://github.com/octopt/techblog/blob/master/vae/main.py

参考文献

  1. https://en.wikipedia.org/wiki/Law_of_total_probability
  2. https://wiseodd.github.io/techblog/2016/12/10/variational-autoencoder/
  3. https://towardsdatascience.com/understanding-variational-autoencoders-vaes-f70510919f73

おすすめの記事

敵対的生成ネットワーク(GAN)

GANは画像処理分野でセンセーショナルな話題を巻き起こした技術です。例えば馬をシマウマにしたり、色々な人の顔画像を作ったりすることが事が行えるようになります。画像処理界を相当ザワつかせた技術を今回は解説いたします。(どれくらい話題になったかはこちらをみると解ると思います)

GANは比較的難しい概念&技術です。表面的な解説は他のWEBサイトやQiitaなどでも取り上げられています。今回はオリジナルの論文から数式やアルゴリズムにどういう意味があるのかということについて解説し、最後にPyTorchによる実装例を紹介します。

GANとは

GANには2つのモジュールGenerator(生成者)、Discriminator(識別者)のがあります。これをニューラルネットワークで作っていくのです。説明のために画像処理の技術として話を進めていきます。Generatorは画像を作る、Discriminatorは画像を識別する技術を表します。

例えばシマウマ画像をGeneratorは作る、Discrimanatorは本物のシマウマの画像かを識別する器を作っていきます。

GANは Generative Adversarial Nets の略ですが、 Generative (生成する)、 Adversarial (敵対者)という言葉が入っています。これは、互いに騙し合うモデルを作る、というコンセプトから来ています。絶対に騙そうとする者絶対に判別してやる者という、いわば矛と盾のようなものを学習により作っていくのです。GANはGANsと表現する事がありますが、これは語尾がNetsとなっているからであり、どちらも変わりません。

、騙す側はGeneratorと呼ばれます。後述しますが、学習が進むにつれて相手を騙すほどの画像が作れるようになるのです。GANの目的は素晴らしいGenereatorを作ることです。

、判別側はDiscriminatorと呼ばれます。日本語では判別器、識別機などとも呼ばれています。こちらは最終的に本物か偽物かを判断します。実装での出力値は確率で出てきますが、一番最後の最後にシグモイド関数をかませて0か1か(正か偽か)を出力します。

GANはこうしたGenerator & Discriminatorというコンセプトを用いた学習方法です。今様々なGANがありますが、なぜ沢山あるのかと言えば、 目的や実装方法によって名前が変えるためです。したがって DCGAN, LAPGAN , SRGAN, StackGAN なども全ては広くGANの一種、というような言い方が可能です。 GeneratorとDiscriminator というコンセプトを使って学習していくモデルがGANということなのです。

察しのいい人は気づいたかもしれませんが、盾であるDiscriminatorの学習はGeneratorよりもずっと簡単です。ラベル付き画像の学習であり伝統的なNNそのものとなります。

GANの目的はGeneratorを作ることです。それを忘れないようにしましょう。学習したGeneratorを使うことで人間も騙せる画像を作っていけるのです。

GANの学習の流れ

Generator

Generatorは適当な入力値をランダム値でもらい、ターゲットとなる画像を生成します。100次元程度のランダムな値が入力値として良く利用されます。生成した画像をDiscriminatorに渡し、正か偽かを判定してもらいます。Discriminatorの判定結果を受け、騙せたか騙せていないかの2値からバイナリクロスエントロピーによりロスを計算し誤差伝搬をして、Generator内部のネットワークの重みを更新していきます。

  1. ランダムノイズを作成する
  2. ランダムノイズから画像を作成する
  3. Discriminatorから真か偽かの判定をもらう
  4. Discriminatorからの判定をもとにロスを計算する
  5. Discriminator&Generatorを通して更新すべき重みの値を受け取る
  6. Generatorのみネットワークの重みを更新する
https://towardsdatascience.com/understanding-generative-adversarial-networks-gans-cd6e4651a29

Discriminator

Discriminatorは、Generatorが出力した偽画像と、予め用意してある本物の画像を次々と入力して学習します。最終的にはシグモイド関数を利用してTrue もしくはFalseを判定結果として出力し、正解画像、偽画像が正しく識別できたかどうかを比較します。出力値は2値なのでGenerator同様にloss関数としてbinary cross-entropyを利用して精度をあげていきます。流れとしては次のとおりです。

  1. 本物の画像とGeneratorが作成した偽画像を仕分ける
  2. 真画像を偽と判定した、あるいは偽画像を真と判定したロスを計算し、ペナルティを与える
  3. ネットワークの重みを更新する。

Note(注意)

Discriminatorの学習は簡単です。Generatorから偽画像を生成してもらって、それと正解画像を入力して正答率を上げるだけですので、見てみればよくある典型的なニューラルネットです。

Generatorの学習でユニークなところはDiscriminatorを噛ませて出力しているところです。GeneratorはDiscriminatorの出力値を見ながら、騙せるような画像を作っていくのです。Generatorの学習注意上で重要なのは、誤差伝搬(Back propagation)の際にはDiscriminatorの重みは更新しないようにする事です。ただし、学習時には連結しているのでGeneratorへの重み伝搬の際にはDiscriminator内部も通っていくことになります。

https://www.freecodecamp.org/news/an-intuitive-introduction-to-generative-adversarial-networks-gans-7a2264a81394/

学習はDiscriminatorとGeneratorをループでぶん回して学習していくことになります。エポック数と言われるものが、全体のループを決めるものとなります。

最初Generatorは全然学習できていないのでノイズっぽいデータが出てくることになるでしょう。Discriminatorも判定が出来ないので全く判別できないはずです。エポック数が多くになるにつれて判別ができるようになってきます。

オリジナルの論文で言及していますが、Generatorの学習は十分なDiscriminatorの学習が出来ないのであればしないほうが良いということを行っています。the Helvetica scenarioを避けるためというような、つまらないイギリシアンジョークをいれていますが、要するにDiscriminatorの学習精度を上げるためGeneratorよりも多く学習することが大事です。

さて、今までの説明をもとに疑似コードを見て全体像を掴んでみましょう。

擬似コード

# 200回Generatorを学習                                                                                                                                                                                                                                                        
for epoch in range( 200 ):                                                                                                                                                                                       
    for j in range( 20 ): # Discriminator。Generatorよりも多く学習する。              
        # 偽画像をGから生成する。最初はそれこそランダムっぽい画像が出るが、学習に従って段々と精度が上がる                                                                                                                                                                       
        fake_images = G.generate_images()
        # 本物の画像を取得。                                                                                                                                                                                                                                                    
        true_images = get_correct_images()

        # 偽物の画像を取り出していき学習                                                                                                                                                                                                                                        
        for f_img in fake_images:
            # False or True(0,1)で結果が帰ってくる。                                                                                                                                                                                                                            
            answer = D.check( f_img )
            # 偽画像と判定するべきなので、正解であるFalseを教えて重みを更新                                                                                                                                                                                                        
            D.update( answer, False )
        # 本物の画像をとりだしていく(上記と同じ事を正解画像でやる)                                                                                                                                                                                                        
        for t_img in true_images:
            answer = D.check( t_img )
            D.update( answer, True ) #正解画像なのでTrueを渡す。
    # ----------------                                                                                                                                                                                                                                                          
    # Discriminatorの学習が終わったので、Generatorの学習をする。                                                                                                                                                                                                                        
    # ----------------                                                                                                                                                                                                                                                          
    # UpdateされたDを用いて学習する                                                                                                                                                                                                                                             
    G.set_discriminator( D )
    # 画像を生成するためのシードを作ってやる                                                                                                                                                                                                                                    
    random_images = get_random_image()
    # シードから画像を取り出す                                                                                                                                                                                                                                                  
    for r_img in random_images:
        # G.check内ではFake画像を生成し、Dに判別させ、結果を得る作業が入っている                                                                                                                                                                                  
        answer = G.check( r_img )
        # 得た結果からGの重みを更新。なお、この際にセットしたDの重みは更新しない。                                                                                                                                                                                                              
        G.update( answer )

上記は実装よりの疑似コードですが、概要を知るためにじっくりと眺めてください。そして、上記を見ないで自分で擬似コードを書いてみましょう。

機械学習で最も大事なのはコンセプトの理解です。第三者が書いたコードを写経やコピペしてすぐに走らせたくなる気持ちはわかりますが、こうした一見複雑な仕組みの理解には自分で疑似コードを書くことがおすすめです。他の人のソースコードのコピペでは理解することは難しいでしょう。

GANの数式

GANでは次の式が利用されています。

\underset{G}{\text{min}} \underset{D}{\text{max}}V(D,G) = \mathbb{E}_{x\sim p_{data}(x)}\big[logD(x)\big] + \mathbb{E}_{z\sim p_{z}(z)}\big[log(1-D(G(z)))\big]

一見摩訶不思議ですが、実は非常に簡単です。Gは最小化になるように、Dは値が最大になるようにしたいという意味が込められています

G(z)はFake画像です。zという潜在変数をGという関数にいれて画像を生成することを意味しています。さらにそれをDiscriminatorの関数に噛ませたものがD( G( z ) )と表現されます。

Generatorにとってみると、D(G(z))が1になる、つまり本物と誤解するようにしたい、という意味です。

Discriminatorにとっては左辺は無視します。log( D(x) )は本物のデータを用いる、当然1になるためです。右辺に注目して、log( 1 – D( G(x) ) )において、 D( G(x) )が0になるように頑張るのです。

Generatorにとっては最小化、Discrimanatorにとっては最大化する、というのはこのためです。

EはExpected Lossなのですが、添字のx〜pdata(x)とは確率分布pdataからxを独立的にサンプリングするという意味になります。Eは期待値ですので、イメージ的には全てを足し合わせてサンプル数で割った値となります。例えば200人の人の身長の高さの期待値は次のようになります。

{\Bbb E}[h]=(\sum_{n=1}^{200}h_n)/200

pdata(x) の範囲からxとしてサンプリングしていき、適用して、期待値として最大化する(最小化する)というような意味が込められています。

このような数式の表現の方法、ルール、解説についてhttps://www.hellocybernetics.tech/entry/2018/07/16/234815 に詳しく書いてあります。わからない方は読んでみてください。

PyTorchに依る実装

Pytorchによる実装を示します。PyTorchはプリファード社(Chainerを作っていた会社)が推奨した後継のライブラリです。kerasもいいですが、私は PyTorchのほうがPythonライクで好きです。

まずは画像の取得関数を定義します。

画像取得関数

# 画像取り出し。
import os
from torchvision import datasets
import torchvision.transforms as transforms

def get_dataloader():
    location = "data/mnist"
    os.makedirs(location, exist_ok=True)
    dataloader = torch.utils.data.DataLoader(
	datasets.MNIST(
	    location,
            train=True,
            download=True,
            transform=transforms.Compose(
                [ transforms.Resize( 28 ), transforms.ToTensor(), transforms.Normalize([0.5], [0.5])]
            ),
        ),
        batch_size=64,
        shuffle=True,
    )
    return dataloader

単に画像をdata/mnistに保存すると言うだけの画像取得ローダーです。今回は本質にかかわらないので詳しくは説明しません。ただ、今後自分で何か学習用の画像を手に入れた際はローダーを自分で定義していくことになるので、どこかで使い方をマスターする必要があります。

続いて、GeneratorとDiscriminatorのクラスを定義します。最初はGeneratorクラスです。

Generatorクラス

import torch.nn as nn
class Generator( nn.Module ):
    def __init__( self, z_dim = 100, channel = 1, w = 28, h = 28 ):
        super().__init__()
        self.latent_dim = z_dim
        self.img_channels = channel
        self.img_width = w
        self.img_height = h
        self.img_shape = ( self.img_channels, self.img_width, self.img_height )

        def _block( in_feat, out_feat, normalize ):
            layers = [nn.Linear(in_feat, out_feat)]
            if normalize:
                layers.append( nn.BatchNorm1d( out_feat ) )
            layers.append( nn.LeakyReLU( 0.2 ) )
            return layers

        self.model = nn.Sequential(
            *_block( self.latent_dim, 128, normalize=False ),
            *_block( 128, 256, normalize=True ),
            *_block( 256, 512, normalize=True ),
            *_block( 512, 1024, normalize=True ),
            nn.Linear( 1024, int( np.prod( self.img_shape ) ) ),
            nn.Tanh()
        )
    def forward( self, z ):
        img = self.model( z )
        img = img.view( img.size( 0 ), self.img_channels, self.img_width, self.img_height )
        return img

初期化関数__init__内部ではmodelを作成していきます。Generatorは潜在変数zを受け取って疑似画像を作成しますので、潜在変数zの次元数を受け取れるように設定しています。

nn.Sequentialの内部を見るとわかりますが、まずは100次元を受取、128次元に、128次元からノーマライズして…と繰り返し1024次元に変更した後、にnp.prodを用いいて画像の最終的な次元に展開しています。normalized処理がないと値が安定しないので必ず入れるようにします。

np.prodというのは要素内の全てを掛け合わせるという意味です。img.size( 0 )はバッチ数を意味していて、最初に説明したdata-loaderの設定にもよるのですが64を返します。 ミニバッチ数とは、例えば入力を1枚1枚でなく、64枚の画像単位(バッチ単位)で学習するという意味です。

続いてDiscriminatorの実装例を表示します。

Discriminatorクラス

class Discriminator( nn.Module ):
    def __init__(self, channel = 1, w = 28, h = 28):
        super().__init__()

        self.img_channels = channel
        self.img_width = w
        self.img_height = h

        self.img_shape = ( self.img_channels, self.img_width, self.img_height )

        self.model = nn.Sequential(
            nn.Linear( int( np.prod( self.img_shape ) ), 512),
            nn.LeakyReLU( 0.2 ),
            nn.Linear( 512, 256 ),
            nn.LeakyReLU( 0.2 ),
            nn.Linear( 256, 1 ),
            nn.Sigmoid(),
        )

    def forward( self, img ):
        img_flat = img.view( img.size( 0 ), -1 )
        validity = self.model( img_flat )
        return validity

Generator同様にimg_shapeは画像のチェネル数(RGBなら3チャンネル、グレースケールなら1チャンネル)、画像縦、画像横サイズを保持しています。こいつをnp.prodすることにより、例えば3チャンネル16×16の画像なら768次元に全て展開されます。その次元をだんだんと落とし込んでいき、最後には1次元にしてSigmoid関数に噛ませていきます。

Discriminatorは本物かどうかをYes/Noで判定する学習機でした。そのため、次元を少なくしていきます。最後にシグモイド関数をいれて強制的に0か1にします。シグモイド関数をいれない直前に関しては確率密度関数といわれます。(ここでは割愛)

さて、forward関数ではimg.size( 0 )でミニバッチ数をとりだし、-1を渡して自動展開しています。それをモデルに突っ込んで0か1を受け取っています。

これで準備は整いました。それでは学習プログラムをつくります。メインのプログラムを下に記します。

メイン関数

import torch

def main()    
    batch_size = 64
    # 色々と初期化                                                                                                                                                                                                                                                                                                                                                                                          
    Tensor = torch.cuda.FloatTensor # Tensor = torch.FloatTensor
    generator     = Generator().cuda()
    optimizer_G   = torch.optim.Adam( generator.parameters(), lr=0.0002, betas=( 0.5, 0.999 ) )
    discriminator = Discriminator().cuda()
    optimizer_D   = torch.optim.Adam( discriminator.parameters(), lr=0.0002, betas=( 0.5, 0.999 ) )
    # ロス関数の初期化                                                                                                                                                                                                                                                                                                                                                                                      
    adversarial_loss = torch.nn.BCELoss().cuda()

    epoch_size = 200 # 普通は100-200くらい。                                                                                                                                                                                                                                                                                                                                                                
    for epoch in range( epoch_size ):
        dataloader = get_dataloader()
        for i, ( real_images, some ) in enumerate( dataloader ):
            batch_size = real_images.size( 0 )
            # 正解と不正解のラベルを作る                                                                                                                                                                                                                                                                                                                                                                    
            valid = torch.ones( (batch_size,1), requires_grad=False ).cuda()                                                                                                                                                                                                                                                                                                   
            fake = torch.zeros( (batch_size,1), requires_grad=False ).cuda()
            # ---------------------                                                                                                                                                                                                                                                                                                                                                                         
            #  Dの学習                                                                                                                                                                                                                                                                                                                                                                                      
            # ---------------------                                                                                                                                                                                                                                                                                                                                                                         
            # DはGより20回多く学習をさせる。( オリジナルの論文より)                                                                                                                                                                                                                                                                                                                                      
            for j in range( 20 ):
                # まず初期化                                                                                                                                                                                                                                                                                                                                                                                
                optimizer_D.zero_grad()                                                                                                                                                                                                                                                                                                                                    
                # 偽画像の作成                                                                                                                                                                                                                                                                                                                                                                              
                # ランダムな潜在変数を作成                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         
                z = torch.empty( real_images.shape[0], 100,requires_grad=False ).normal_( mean = 0, std = 1 ).cuda()
                # fake imageを取得                                                                                                                                                                                                                                                                                                                                                                          
                fake_images = generator( z )
                # ロスの計算.                                                                                                                                                                                                                                                                                                                                                                               
                real_loss = adversarial_loss( discriminator( real_images.type( Tensor ) ), valid )
                fake_loss = adversarial_loss( discriminator( fake_images.detach() ), fake )
                d_loss = (real_loss + fake_loss) / 2
                # 勾配を計算                                                                                                                                                                                                                                                                                                                                                                                
                d_loss.backward()
                # 伝搬処理。Dにだけ誤差伝搬される                                                                                                                                                                                                                                                                                                                                                           
                optimizer_D.step()
            # ---------------------                                                                                                                                                                                                                                                                                                                                                                         
            #  Gの学習                                                                                                                                                                                                                                                                                                                                                                                      
            # ---------------------                                                                                                                                                                                                                                                                                                                                                                         
            # まず初期化                                                                                                                                                                                                                                                                                                                                                                                    
            optimizer_G.zero_grad()
            # ランダムな潜在変数を作成                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
            z = torch.empty( real_images.shape[0], 100,requires_grad=False ).normal_( mean = 0, std = 1 ).cuda()
            # fake imageを取得                                                                                                                                                                                                                                                                                                                                                                              
            fake_images = generator( z )
            # discriminatorを利用して結果を取得する                                                                                                                                                                                                                                                                                                                                                         
            g_loss = adversarial_loss(discriminator( fake_images ), valid )
            # 勾配を計算                                                                                                                                                                                                                                                                                                                                                                                    
            g_loss.backward()
            # 重みを更新する。Gのみにだけ勾配伝搬処理がされる                                                                                                                                                                                                                                                                                                                                               
            optimizer_G.step()

            print(
                "[Epoch %d/%d] [Batch %d/%d] [D loss: %f] [G loss: %f]"
                % (epoch, epoch_size, i, len(dataloader), d_loss.item(), g_loss.item())
                )

            batches_done = epoch * len(dataloader) + i
            if batches_done % 400 == 0:
                save_image(fake_images.data[:25], "images/%d.png" % batches_done, nrow=5, normalize=True)

optimizerはそれぞれ、Discriminator とGeneratorで設定します。

ロス関数はバイナリ値(0もしくは1)なのでその関数をセットします。

Valid, Fakeは単に正解ラベルとして差分を計算するために出力しているだけです。

Pseudo-codeで記載したとおりまずはDの学習を先行します。Discriminatorの学習率をアップしたほうが学習結果が良いためです。

Generatorが吐き出した学習結果は次のとおりとなりました。

段々と精度が上がってきているかわかります。見ていると、どうも7,9,1が多いです。こうした減少はよく知られている現象(モード崩壊)で、それを避けるためのテクニックも随所論文で見られます。

ソースコード

https://github.com/octopt/techblog/blob/master/gan/main.py

Githubに上げておりますので参考にしてください。

最後に

GANを作っていくと、学習していくと面白いことに気づきます。例えばGeneratorはよく出来た文字を作り出します。人間でも間違うくらいです。というよりは間違えます。あたかも人間が間違えた文字を拒否する構造は正しいのでしょうか?たしかにそれはGeneratorが作ったものですが、人間が作ったものと同じかもしれません。こうしたことにはどう対処していくべきでしょうか?こんなことも考えながら色々と工夫をしていくと面白いと思います。

手書き文字ではかなりシンプルでした。人間の顔などで作っていくとまた面白い結果が出るでしょう。

参考文献

  1. https://papers.nips.cc/paper/5423-generative-adversarial-nets.pdf
  2. https://towardsdatascience.com/understanding-generative-adversarial-networks-gans-cd6e4651a29
  3. https://medium.com/deeper-learning/glossary-of-deep-learning-batch-normalisation-8266dcd2fa82
  4. https://github.com/eriklindernoren/PyTorch-GAN

次におすすめの記事

強化学習( Reinforcement Learning )

はじめに

強化学習で最も基礎となるQ学習についてアルゴリズムを解説します。

ゲームをコンピューターに学習させてクリアさせたり、囲碁を学習させて囲碁世界チャンプに勝つなど、コンピューターにゲームやらせることを聞いた人は多いと思います。世の中ではAIなんて呼ばれていますね。

このようなタイプの機械学習は強化学習とよばれています。SVMやランダムフォレスト、ニューラルネットワークとは違うタイプの機械学習です。今回はそんな強化学習について説明します。

強化学習での用語

強化学習を犬の調教を例に考えます。

犬に芸を仕込むときは餌を使います。例えば「お手」の場合は飼い主が「お手!」と発声して、お手が出来たら餌を与えます。何もしない、或いは違う芸をした場合は何も与えない、更には棒で叩くなど懲罰を与えるかもしれません。強化学習ではまさにコレをコンピューターで行うのです。

先の犬の調教例をもとに、実際に使われる強化学習の用語を説明します。

  • Agent( エージェント )
  • State ( 状態)
    • 「犬が部屋にいる状態でお座りと発声する」等
  • Action( 行動 )
    • お手をする、お代わりをする、伏せをする、おすわりする等
  • Reward( 報酬 )
    • おやつを上げる
  • Penalty( ペナルティ )
    • なにもしない、棒で叩く等

State数はかなり膨大な数となります。例えば「犬が伏せている状態でお手」と「犬がおすわりしている状態でお手」は違う状態だからです。

Action数も同様に数が多くなります。

注意ですがActionはエージェントである犬の行動のみに注目するのに注意してください。人間の「お手!」と発声する行為はActionではありません。Agentの行動ではないためです。お手の発声はStateの一つということになります。

Q学習(Q-learning)

強化学習の実装には色々なアルゴリズムがありますが、最も有名でかつ一番はじめに知るべきアルゴリズムはQ学習です。様々なアルゴリズムはQ学習がベースに作られています。

Q学習ではQ関数というものを用いていますが、この関数の改良版だったりするのが、最新のアルゴリズムであったりします。そのため、まずは本Q学習の仕組みを見ていきます。

Q学習とは

現在の状態から最善のアクションを見つけ出す学習アルゴリズムです。Q学習のQとはQulaityのQであり、ここでいうQualityとは特定のアクションをした際に得られる高い報酬クオリティをもらう、という意味からきています。

今見てもさっぱりだと思いますが、Q学習の数式をみてみます。

Q({\small state}, {\small action}) \leftarrow (1 - \alpha) Q({\small state}, {\small action}) + \alpha \Big({\small reward} + \gamma \max_{a} Q({\small next \ state}, {\small all \ actions})\Big)

上記式は次のようにも表されます。上式と下式は実は展開しているだけで中身は全く同じです。

Q(state,action) \leftarrow Q(state,action) + \alpha \bigl(R(state,action) + \gamma \max_{action' \in A(state')} Q(state', action') -Q(state,action) \bigr)

数式がどういう意味かは後述します。今は深く見ません。とりあえず上記式の中で最も重要なQ( state, action )に着目します。この関数さえわかればQ学習がわかってきます。このQ( state, action )はQ関数、Q-tableとも呼ばれています。

Q関数(Q-table)とは

Q関数はStateとActionという2つの変数からなる関数です。

Q関数はテーブルで表され、値Q-valueを保持しています。このQ-valueを正しく返していくように学習(更新)していくことが今回のQ学習の目的です。

Q関数(Q-table)は次のように表されています。

Q-tableAction 0Action 1…..Action N
State 000….0
State 100….0
….00….0
State N00….0

Q関数は最初は0で初期化します。ランダムで初期化するという書かれているサイトもありますが、通常は0で初期化します。エージェントは基本的にはこのQ-tableが最大値を選択するように行動します。

Q関数はState, Actionの2変数をとる説明しましたが、引数であるStateは、例えば「お手」「おすわり」の発声、犬の位置(x,y)、発声社の位置(x,y)など様々な変数が関わります。プログラム的に書けば次のようになるでしょう。

State[ dog_state ][ dog_posx ][ dog_posy ][ owner_action ][ owner_posx ][ owner_posy ] …..

上記のように多次元配列の形となったりします。Actionも同様です。実装方法によってはQ-table自体が複数の変数で表現されているケースもあります。これは皆さん実装する上で決定します。

なお、Q-valueの値が報酬であるというように記載されているサイトがありますが間違いです。厳密には報酬ではなく、今までの獲得報酬量や他の行動からの影響値などの合計値となっています。

報酬は、犬の例で言えばおやつを上げる、というものです。

Q-valueには今まで獲得したペナルティーの値も入っていたりします。Q-valueは次にとるべき行動の指針として利用されるものです。

Q-table(Q値)の更新

Q-tableの更新には2つの方法があります。

  • Explore(探索)
  • Exploit(活用)

ExploitではQテーブルを参照し、与えられた状態からすべての取りうるアクションを検討するという手法です。すなわち、Q-tableの高い値をもとに行動をすすめます。

一方でExploreは探索、冒険するという意味です。先のExploitでは知り得た情報をもとに次の行動を決定していましたが、そうした情報を気にせず、行動するという意味になります。Exploreでは次のステートをランダムで決定します。

Exploreは非常に重要です。Q-tableを常に見るExploit戦略では、一番最初に値が更新されたとしたら、テーブルでは最初に0で初期化されているため、次の学習からは常にそこの探索しかしなくなることになります。新しい探索をしなければ永遠に同じ行動しか起こらなくなり、Q-tableの更新が行われないのです。

Exploit か Exploreか。どちらかを決定するために、よくイプシロン(ε)という変数を用いてどのような間隔でExploitかExploreを採用すべきかを決定することができます。例えば30%の確率でランダムにExploreするコードは次のようになります。ただのIF文です。実はε-greedy行動選択なんてかっこいい呼び方がありますが、ただのIF文ですので何も難しいことはありません。実際に次のようなコードが利用されています。

epsilon = 0.3
if random.random() < epsilon:
    // Explore
else:
        // Exploit

Q値の更新は都度実施されます。スーパーマリオブラザーズで例えれば、右に行ったり、ジャンプする、ノコノコを踏んづける、ステージクリア、マリオが落ちて死ぬ、あるいは全クリするなどの終了条件です。これをエピソード単位(任天堂のスーパーマリオブラザーズでいうところのマリオの機数)で回していきます。エピソードは何回やるか決めておきます。一つのエピソードが終わるのは終了条件に達したときとなります。終了条件は全クリやマリオが落ちたときなど、自由に決めておきます。多くのエピソードをぶん回し、さまざまなエピソードでQ-tableを学習させていくのがQ学習です。

なお、終了条件をヒットしたものに関しては報酬がマイナスを取るなどの低い値をわたします。これがペナルティです。エピソードが進んでいけばいくほど、エージェントは学習していきます。

数式をもう一度

Q関数が理解できたところで、数式をもう一度見返してみます。

Q({\small state}, {\small action}) \leftarrow (1 - \alpha) Q({\small state}, {\small action}) + \alpha \Big({\small reward} + \gamma \max_{a} Q({\small next \ state}, {\small all \ actions})\Big)

αはQ値の新しい値と古い値をどちらを優先するのかという比率を表します。

γは割引率と良い、将来のQ値の値を左右します。割引率は通常 0.8 から 0.99をとります。

rewardは何か特定の値をしたときの報酬となります。この報酬は別途テーブルで持っておきます。特定の条件で与えて良いのですが、それは自分で決めていきます。もちろんPenaltyも同様です。上記式にPenaltyは無いですが Reward = Penalty + Rewardと考えてください。

max(​Q(next state,all actions))という部分ですが、これは、次のStateに遷移した際に、取り得るアクションをすべて実施し、最も高かったアクションを選択するという意味となります。なお、一番はじめにスタートした際には、Q-tableは全てゼロでした。そのため最初はQ(next state,all actions)は必ず0が返却されることになるでしょう。再帰的にすすんでいくのかな、とか考える人も多いのですが、まったくその必要はなく純粋にテーブルの値だけ見て勧めていきます。

流れのまとめ

3ステップでQ学習は実施されます

  1. Agent は state (s1) からはじめ、action (a1) を実施し、 reward (r1)をうけとります。
  2. Agent 次のアクションを Q-tableから最も高い値をしめすものを選択肢実施する もしくは ランダムに行動します(epsilon, ε)
  3. q-valueをアップデートします。

こうしてみると意外と簡単な作業だったんだな、と理解ができます。

Deep Q-learning

ここでは深く話しませんが、簡単にいえばQ-tableを学習でなんとかもとめちゃう、という話です。時間やリクエストがあれば記事を書いていきます。

参考文献

次のおすすめの記事

単純ベイズ分類器(Naive Bayes Classifier )

はじめに

ベイズ定理を利用した分類機を、ナイーブベイズ分類器とよびます。スパムフィルターとして使われており、聞いたことがある人も少なくないと思います。ベイズ分類器は実装が簡単な上に、出力値が確率であるため扱いやすく、ポピュラーな学習モデルです。 当サイトでは長い理論説明はせず、必要最低限の数式をで説明していこうと思います。

具体的なベイズ定理

男性のタバコを吸う人が肺癌になる確率 を求めます。タバコを吸ったら肺がんになるという確率は普通はわかり得ません。ただこの確率が、世の中の肺がん率、喫煙率、肺癌患者の喫煙率が解れば、ベイズ推定で求めることができるのです。

ベイズ式は以下のように表されます。

Pr( A | B ) = Pr( B | A ) * Pr( A ) / Pr( B )

それぞれは次のとおり定義します。

  1. Pr( A ) = 肺癌率
  2. Pr( B ) = 喫煙率
  3. Pr( A | B ) = 喫煙者の人が肺癌になる確率
  4. Pr( B | A ) = 肺癌の人が喫煙者である確率

なお、サイトに寄っては次のようにも書かれたりしています。

P(A|B) =\frac{P(A \cap B )}{P(B)} = \frac{P(B|A)P(A)}{P(B)}

罹患率等のデータの取得

実際に喫煙者の人が肺癌になる確率を求めます。喫煙率、肺がん率、肺癌患者の喫煙率を入手する必要があり、ネットで検索してみました。

男性の肺癌患者の30%はタバコによるガンだそうです[1]。そこで、式4. 喫煙者の人が肺癌になる確率は次のように定義できます。

4. Pr( B | A )=0.3 (喫煙者の人が肺癌になる確率)

続いて、肺癌率は男性の場合は7.4%[2], 男性の喫煙率は29.4%[3]なので, Pr(A), Pr(B)はそれぞれ次のように定義できます。

1. Pr(A) = 0.074(男性肺癌率)

2. Pr( B ) = 0.294 ( 男性喫煙率)

必要な確率は全て知り得たので、最終的なタバコを吸うと肺癌になる確率を求めます。最初の数式にそれぞれの確率を当てはめます。

Pr( A | B ) = 0.3 * 0.074 / 0.294 = 0.075

上記の通り、約7.5%の確率でタバコを吸うと肺癌になることが判明しました。通常、タバコを吸って肺癌になる確率は解るはずはありませんが、ベイズの定理を用いて求めることができるようになるのです。しかしながら、喫煙は8%弱肺癌に寄与するというのはなんとも面白い結果となりました。

ベイズ分類器

ベイズの定理を見たところで、実際にをどうやって分類器として力を発揮できるかを考えます。

ベイズ分類器では、左側のベイズ理論を右側のように仮定していきます。

P(Y|X) = \frac{P(X|Y) \times P(Y)}{P(X)}

上記式は次のようにベイズ分類機では意味すると当てはめています。

Posterior = \frac{Likelifood \times prior }{ evidence }

なお、Likefoodは尤度、Priorは事前分布、Evidenceは周辺尤度と呼ばれています。

尤度であるP(X|Y) 、それから事前分布であるP(Y)は学習データから入手します。今次のような学習データがあったとします。次の学習データセットはエジンバラ大学の教材[4]を参考にしました。以下の表は頻度表といわれます。

天気気温湿度風速ゴルフ
晴れ暑い高いなしNO
晴れ暑い高いありNO
曇り暑い高いなしYES
雨天普通高いなしYES
雨天寒い普通なしYES
雨天寒い普通ありNO
曇り寒い普通ありYES
晴れ普通高いなしNO
晴れ寒い普通なしYES
雨天普通普通なしYES
晴れ普通普通ありYES
曇り普通高いありYES
曇り暑い普通なしYES
雨天普通高いありNO

ここで、X=( 天気、気温、湿度、風速), そしてY=( ゴルフ )となります。YのYESはゴルフした、Noはゴルフをしなかったという意味です

P( X | Y ) と P( Y )を上記の表から当てはめます。

P(Y=YES)=\frac{ YES 数}{ YES 数+NO数} = \frac{9}{14}

P(X=晴れ|Y=Play) = \frac{ YES 数 \cap 晴れ数}{ YES 数} = \frac{2}{9}

P(X|Y)のXは1つだけで説明しましたが、Xが複数条件の時も知りたくなるかもしれません例えば外が晴れ&寒い時にはゴルフするのかどうかなどです。一件わかりますが、寒い&晴れているなどのXの条件数が多くなるほど計算は複雑になります。状態数が多くなるためです。

そこでXの要素はすべて独立しているという仮定を置きます。そうすることで次のように書き換えることが可能となります。

P(X=晴れ,X=寒い| Y ) = P(X=晴れ|Y) P(X=寒い|Y)

独立という仮定をおくことで、分割することができるようになるのです。こうすることでパラメータ数は非常にが少なくなり(具体的には指数関数が線形になり)高次元のデータでも扱うことができるようになります。

データが独立という仮定を置かれている所以がナイーブ(雑)と言われている理由です。

迷惑メールフィルター実装例

実際にベイズ推定を用いた迷惑メールフィルターがどのように作られるのか説明します。

迷惑メール 30通中を受信したとします。迷惑メールの中で次の単語「バイアグラ」、「限定品」、「機械学習」という単語が含まれた表、頻度表を作成します。次のようになりました。

バイアグラ限定品機械学習
回数7170

迷惑メールが30通ですので、ここから尤度表を作成することができます。単に母数で除算するだけです。

バイアグラ限定品機械学習
YES7/3017/300/30
NO23/3013/3030/30

尤度表から、例えば「バイアグラ」が入り、「限定品」が入っていない場合の迷惑メールの確率 P( 迷惑メール | バイアグラ ∧~限定品)を求めることができます。

\frac{P( バイアグラ \cap\lnot限定品 | 迷惑メール) P(迷惑メール)}{ P( バイアグラ\cap \lnot 限定品 )}

さて、ここで∧の条件は独立である場合分解できるということを説明しました。そのため、上記式は次のように分解できます。

\frac{P( バイアグラ| 迷惑メール) P( \lnot 限定品 | 迷惑メール) P(迷惑メール)}{ P( バイアグラ ) P( \lnot 限定品 ) }

続いて迷惑メールでない 正常なメール100通のうち、先程の単語についての尤度表を作成してみます。

バイアグラ限定品機械学習
YES1/1001/1007/100
NO99/10029/10023/100

ゆう土俵から、正常なメールの確率は次のとおりとなります。

\frac{P( バイアグラ| 正常メール) P( \lnot 限定品 | 正常メール) P( 正常 メール)}{ P( バイアグラ ) P( \lnot 限定品 ) }

これで準備が整いました。メールに「バイアグラ」& NOT「限定品」 の条件 で迷惑メールと正常メールのどちらに分類されるかの確率を求めます。今、分母が共通していますので、分子の部分だけに着目して計算をします。(何故分母は無視したのかは後述します)

迷惑メールの尤度= P( バイアグラ| 迷惑メール) P( \lnot 限定品 | 迷惑メール) P(迷惑メール) = 7/30 * 13/30 * 30/130= 0.023

正常メールの尤度= P( バイアグラ| 正常 メール) P( \lnot 限定品 | 正常 メール) P( 正常 メール) = 1/100 * 29/100 * 100/130 = 0.0022

実はこれだけで、この種のメッセージが迷惑メールである確率をもとめることができます。次式で求まります。

0.023/(0.023 + 0.0022 )=0.912

よく見るとわかりますが、算出された合計の尤度で、該当尤度を除算するだけです。結果として、バイアグラを含み、限定品を含まないメールは、おおよそ91.2%が迷惑メールであるということがわかりました。このように尤度をすべて足しこんだ値を、該当の尤度で割ることで、それぞれのメールの確率がわかります。

分母を省略した理由ですが、分母が共通しています。確率を計算するときに打ち消し合って消えるので無視できるのです。

参考文献

  1. https://www.sankei.com/life/news/180104/lif1801040007-n1.html
  2. https://www.haigan.gr.jp/guideline/2018/1/1/180101000000.html
  3. https://ganjoho.jp/reg_stat/statistics/stat/smoking.html
  4. http://www.inf.ed.ac.uk/teaching/courses/inf2b/learnSlides/inf2b12-learnlec06.pdf

次に読むのおすすめ記事

再帰型ニューラルネットワーク(RNN)

はじめに

音声データや、文章データなどの時系列データの学習には再起型ニューラルネットワークと呼ばれるRNN(Recurrent Neural Network)が用いられます。

2012年頃、Google社の翻訳機能が劇的に改善された事は記憶に新しいです。この技術のベースはLSTMという技術でありましたが、LSTMはRNNの一種となります。蛇足ですが翻訳エンジンはLSTMを少し工夫しEncoder&Decoderという概念を持ち込んで高精度の翻訳機を完成させたのでした。

本稿ではLSTMのベースとなるRNNについて説明していきます。RNNの活性化関数を工夫したものがLSTMであり、RNNさえ理解できればLSTMも理解ができます。

再起型ニューラルネットワーク(RNN)とは

「再帰」という表現がついていますが、従来のニューラルネットワーク(NN)との違いは、隠れ層の出力データの取り扱い方です。ニューラルネットワークでは隠れ層を横に増やしていき、複雑な学習を行おうとすると横長に広がっていくイメージでした。

一方でRNNは従来のNNとは異なり、1つ前の隠れ層の出力を入力として利用します。そのため、イメージ的には将棋倒しのように答えが出てくる形となります。

オレンジが入力層。灰色が隠れ層、緑が出力層。

RNNを用いたアプリケーション

翻訳エンジンにあっ使われている他、文字推薦や自動文章作成などが具体的な実装事例です。

文字推薦とは携帯電話でのメールなどでおなじみですが「app」と入力したときに本当に入力したい単語 apple, application などを教えてくれる機能です。

文章作成でも[ the dog eats ] と書くと「dog-food」や「my-homework」等の単語の候補を出す技術にRNNがよく利用されています。RNNは前段の隠れ層の出力を使うという特性から連続性のあるものの扱いが上手く表現できると考えられています。

RNNの構造

RNNの構造はシンプルです。入力データと前段の出力結果(隠れ層の出力)を足し込み、活性化関数にかませて結果を出します。出力結果の値は次段におくります。先程も述べたとおり、この出力結果を次段に送るというのがRNNがシーケンシャルなデータを扱えると考えている理由となります。

tanh( w_input *x + w_hidden * h + w_bias * b)が実際に使われる。wは重みを表す。それぞれの重みは入力データ、前段の出力データ、バイアスで異なる

入力データと前段のデータを用いて出力するための関数である活性化関数は、tanhが利用されます。

先程は説明のために端折りましたが、活性化関数tanhにかませるデータは重みを加味して足し込みます。NNと同様にこの重みが肝であり、重み更新処理がRNNでも目的となります。

ちなみになぜtanhが活性化関数として利用されるかというと、勾配を保つために、2次導関数が長い範囲にわたってゼロにならない状態を持続する必要があるのですが、tanhはこれに適していたからです。

実際のRNNの素子の構造

具体的には次のような数式を用いて、出力層、中間層(隠れ層)の値を求めます。

隠れ層(中間層の出力)

h_{value} = tanh( h_{prev}+w_{input} * x )

出力層

y =softmax( w_{out} * h_{value} )

中間層の入力

h_{next} =w_{hidden} * h_{value}

h_nextは次段でh_prevとして扱われます。下図を参考にしてください。

図では簡略化のため重みに関して数字を振っていないが、もちろん重みはそれぞれのベクトルに対して値が異なる。

実際の計算の概略

tanhで計算すると説明いたしましたが、実際にはベクトルデータに対してどうやって計算を適用するのか疑問に思うかもしれません。実際のベクトルやマトリックスを使った演算例を下に記載します。

参考:https://medium.com/towards-artificial-intelligence/whirlwind-tour-of-rnns-a11effb7808f
x1..が入力データh0…が前段からくるデータ。h1…が今回新しく作られた隠れ層の出力。上記はよく見ていくと分かると思います。

RNNの出力結果の取り扱い

RNNの面白いところの一つとして、値の扱われ方があります。例えば前の図で言うところのYの出力ですがy1からy5まで結果を出力しました。学習モデルに応じて、全てのy1からy5の値を参考にしたり、場合によっては最後の後段の結果y5のみを利用する場合などがあります。下図がよく利用されるRNNの値の利用のされ方です。

http://karpathy.github.io/2015/05/21/rnn-effectiveness/

学習データの作り方

学習データの作り方で、例えば単語推薦を実装したい場合ですが、one-hot encodingの処理がよく利用されます。(one-hot encodingに関してはword2vecを参考にしてください)

さて、appleを例にどのように入力データを扱うのか下図のとおりとなります。

appleのうち、pはかぶっているので、同じone-hotベクトル形式になる。aが入力されれば、出力としてpが期待される。pが入力されればpかlが期待される。出力結果は最初はランダムな値になる。これの差分を取り、バックプロパゲーション処理により重みを変え、徐々に差分をゼロにして正解に近づけていく。

出力結果と、正解値を比較し差分を求めて、重みを更新していく処理となります。重み更新にはBPTTという方法で行われます。

Back propagation through time ( BPTT )

RNNは従来のNNとは違い時系列になっている(前段のデータを利用している)ため、少し工夫が必要になってきます。

バックプロパゲーションは、最終的な結果との誤差を、重みベクトルに反映させていく処理となります。更新する重みは全部で3つありました。 w_{out} , w_{hidden} , w_{input} の3つです。

誤差を計算するためのロス関数としてはマルチクラスエントロピーロス関数を利用します。

L( y, \hat{y} ) = -y log( \hat{y} )

w_inputへの誤差(E_w_input)

導出の過程を記載すると長くなるので、結論だけ書きます。

E_w_input = d3x3+d2x2+d1x1+d0⋅x0

w_hiddenへの誤差 E_w_hidden

まずd3, d2, d1, d0は次のように定義します。。

d3 = (\hat{y3}−y3)⋅w_{out}⋅(1−s3^2)

d2 = d3⋅w_{out}⋅(1−s2^2)

d1 = d2⋅w_{out}⋅(1−s1^2)

d0 = d1⋅w_{out}⋅(1−s0^2)

求めたd0-d4を用いいて、差分を計算します。こちらも導出を書くと長くなるので結論を記載します。

E w_{hidden}= d3 s2+d2 s1+d1 s0+d0⋅s−1

w_inputへの誤差 E_w_input

Ew_{input}= (\hat{y3}−y3) s3

補足

詳しい導出過程については参考文献を参考にしてください。[1]

最後に

RNNの詳しい実装方法について記載しました。LSTMは実は活性関数に工夫を入れるだけとなります。

参考文献

[1]https://songhuiming.github.io/pages/2017/08/20/build-recurrent-neural-network-from-scratch/

次のおすすめ記事

ディープラーニング (Deep Learning:CNN )

はじめに

Deep Learning(ディープラーニング)という言葉は、もはや殆どの人が耳にしたことがあると思います。ディープラーニングは何かを調べていくと、今いち何か掴めないという人が多いのではないでしょうか?今回はディープラーニングの1種であるCNN( Convolution Neural Networks)を中心に画像処理を例として ディープラーニングとはなにか、どう学習させていくかを説明いたします。

前段は特徴量作成ステージ。学習により畳み込みフィルターが作られる。後段はClassification。普通のNNとなる。Fully connectedであり、最後にはSOFTMAX関数を用いる。

Deep learningの正体

ディープラーニングはDeep Neural networks(DNN)とも呼ばれており、その正体はニューラルネットワーク(NN)そのものです。ディープラーニングという言葉が流行り始めた際、中身がニューラルネットワークという事を知って、本当に驚きました。というのも、NNはとても古く伝統ある学習アルゴリズムでしたが、ランダムフォレストやSVMの台頭により、誰も利用しない、古い学習アルゴリズムとして扱われていたからです。厳密にはNNとDNNの違いは、特徴量の作成を学習化にとりいれた事でした。今まで画像処理の分野では技術者は頑張って特徴量を作り、その特徴量をNNなどの学習機で学習させていましたが、DNNでは、画像を突っ込めば前段で特徴量を勝手に作ってくれます。(厳密には特徴量を作成するフィルターを作成する。そのフィルターをかませることで特徴量マップを作成する)これは、とても斬新でした。

Forward process

さて、ディープラーニングの一種であるCNNを例にどのようなに学習しているかを説明します。CNNがわかると、DNNの構造がわかり、例えば最近の技術であるDCGANの理解も進みます。

CNNはFowardプロセスとして前段と後段の2つに分けることができます。まずは前段の畳込みとプーリングについて説明します。

前段( 畳込みとプーリング )

1.畳み込み処理

3x3の場合は、上記のように畳み込み処理を行う。畳み込み処理をして出てきた値に対して、 ReLU( max( 0, x ) ) を実施している例が多い。フィルターのサイズについては、経験的に決める。

CNNでやっていることは実は単純です。CNNでは、入力画像にフィルターによる畳込み演算をし、それを特徴量マップとして次の層に渡します。この特徴マップづくりが特徴量を自動でつくる、と言われている部分でもあります。CNNは多層であるということを言いましたが、次の層でも、同様に畳込み演算を実施して、更に特徴量マップを作成します。そして次の層へ…ということを繰り返し実施するのです。何層にするかは自分で決めればいいのですが、大体2層くらいで落ち着かせる事が多くあります。

CNNの肝は特徴量マップ作りですが、これは言い換えればフィルター(畳み込みの重み)を学習により自動で求めることにあります。繰り返しフィルターの重みをを更新していく処理により精度を高めていきます。フィルターの更新にはバックプロパゲーション(誤差伝播)により更新していくのですが、こちらは後述します。

なお、畳込み処理ではReLU処理をする場合がほとんどです。ReLUとはmax( 0, x )というただのMAX処理で、畳こみ値がマイナスであれば0にする操作となります。

2.プーリング処理

Min poolingの例。画素の一番低いピクセル(黒に近いもの)を選択する。Max Poolingであれば白に一番近いものが選ばれれる。Poolingにより特徴マップの次元は削減される。Max Pooling, Min Poolingの他にAverage Pooling等がある。

CNNではPoolingという作業(間引き)を行っています。Poolingは特徴量をマップを作った直後(あるいはReLU処理後)に実施します

最も使われるPooling操作は畳み込み演算後の特徴マップから、ウィンドウの中で最大値だけをとるMaxPoolingです。Poolingを行う最大の目的は次元の削減にあります。またPooling操作は必須ではありません。事実、DCGANなど最近のDNNの一種では行っていません。ただ、伝統的な?CNNでは利用されています。また、至るところでも解説されているので覚えておくといいでしょう。

前段における注意事項

Note 1:入力画像がカラー画像の場合

入力画像がカラー画像のとき、つまりR,G,Bチャンネルが3つあるときですが、フィルターが3次元となります。例えばウィンドウサイズが3x3であれば、RGBを含め3x3x3のフィルターを用意します。このフィルターを適用して、特徴量マップの1つの要素ができます。

Note 2:何枚の特徴量マップを作ればいいのか

各層において、何枚の特徴量マップを作ればいいのかは、自由に決めます。64,128など、霧のいい数字を用いることが多いです。

NOTE 3: 何層作ればいいのか

2層の例が圧倒的に多いですが、こちらも経験的に決めているようです。

後段処理(Classification)

後段処理では、伝統的なニューラルネットワークを行います。例えば、最後に14x14の特徴量MAPが3つできたとします(14x14x3)。これを1次元にすると588次元です(Flattering処理)。これを用いて、普段のNNで使われるテクニックを使い、指定のクラスの次元まで落とし込むのです。SOFTMAXがよく使われています。この方法については、従来のNNそのものであるので割愛します。

Backward process

誤差伝搬はどのようにやるのでしょうか。後段処理についてはNNで今までと変わりません。前段の誤差伝搬処理について解説していきます。

誤差伝搬を行いますが、今までとの逆の操作をしていくことになります。前段処理は次のような流れでした。

画像 –> 特徴量マップ –> ReLU –> MaxPooling

本当は数回特徴量作成とPoolingをやりますが、ここでは簡単に一層で作成したと仮定し説明します。さて、逆操作では以下になります。

d Max Pooling –> d ReLU–>d Convolution(特徴マップ逆操作 )

d Max Pooling

Forward processでのMax Poolingは最大値だけを抽出し、次元を縮小する操作でした。Backward Processでは、次元縮小に利用したフィルターを用いて、後段から来た誤差をもとの大きさにもどしてやります。

d11, d12, d21, d22は後段のNNからの逆伝搬で算出された誤差。

Backword processでは、このMAX Filterを使うので、前段で作ったMAX Filterは保持しておくことが必要です。

d ReLU

ReLUはMAX関数。0より小さければ0,それ以外は1にする関数となる

この操作を図示すると次のとおりです。単純に0より上であれば1にする操作となります。

例えば4x4の特徴量Mapにd ReLUを実施すると次のようになる。

d Convolution

特徴量MAPの作成に利用したフィルターを使い、復元していきます。

はみ出した領域は、0でパディングしておく(つまり、はみ出した部分の重みは考慮しない)

180度転置したものが伝搬で使われるのかについてはForward And Backpropagation in Convolutional Neural Network[1]に詳しく述べられていますので、参照ください。参考サイトは英語ですが、わかりやすい数式で述べられているので、興味ある人は読めるはずです。

参考文献

[1] medium.com/@2017csm1006/forward-and-backpropagation-in-convolutional-neural-network-4dfa96d7b37e

https://towardsdatascience.com/a-comprehensive-guide-to-convolutional-neural-networks-the-eli5-way-3bd2b1164a53

おすすめ記事

Matrix Factorization(MF)

はじめに

Matrix Factorization(MF)について解説していきます。

現在世にある推薦システムは殆どMFをベースに作られているといっても過言ではありません。Simon Funkという人がNetflix Prizeに応募するために開発し、とても話題になりました。SVDを用いて推薦システムを構築したのですが、あるトリックを用いたSVDであったため、中のエンジンはFunk-SVDなんて言われたりしています。他にもNMFを利用したりSVD++など改良が進んでいますが、メインのやり方は本稿の説明でわかるかと思います。

結局、やることは従来のSVDとおなじになる。ただし、求め方で工夫を行う

何故SVDを改良する必要があったのかと言えば、SVDは疎でない(スッカスカでない)行列の解析には素晴らしく良い結果をもたらしていました。しかしながら、商品や映画の推薦となると、全てのユーザーが全てのアイテムを推薦している訳はありません。実際に映画のレーティングの行列を作ると95%のがNaN(未評価)であったということからも、推薦のもとになる行列はとても疎な行列です。この状況を改善しようとした試みがFunkSVDだったというわけです。

概要

Matrix Factorizationの流れを説明します。次のようなレーティングマトリックスを2つ(Item & User )に分けるものです。他のサイトと差別化するために、本ブログでは 一番わかり易い 表ベースの解説をします。今、以下のようなレーティングマトリックスがあったとします。

Movie 1Movie 2Movie 3Movie 4
User 15NaN83
User 277NaN4
User 3NaN677
User 410NaNNaNNaN

上記の行列を2つの行列q, pに分ける事をMFでは行います。

\hat{r}_{ui} = q_i^Tp_u

\hat{r}は推定されたレーティングという意味し、実際のレーティングはrとします。

p_uはユーザー、q_iはアイテムを表す行列です。今回の目的は、ユーザー特徴量行列とアイテム特徴量行列をつくり、それらを掛けると元の行列にする事です。

これは実際のレーティングrと推定レーティング\hat{r}の2乗誤差を最小にするというな行列を分解する,という意味でもあります。式で表せば次のようになります。

minimum( p,q ) \sum_{u,i\in K}(r_{ui}-q_i^Tp_u)^2

p, qを作る

ユーザ特徴量行列p、アイテム特徴量行列qは今は未知なので適当なランダム値で埋めてやります。

p=
Factor 1Factor 2Factor 3
User 11.40.4-1.2
User 21.31.5-0.3
User 30.80.10.6
User 4-0.70.51.1
q=
Movie 1Movie 2Movie 3Movie 4
Factor 10.60.51.3-0.5
Factor 2-1.1-0.40.71.3
Factor 310.30.5-0.1

さて、説明のために、User1のMovie3に注目します。

実際の評価rはレーティングマトリックスをみてみると”8″でした。

ユーザ特徴量行列p(青)ではUser 1は[1.4, 0.4, -1.2]

アイテム特徴量行列q(緑)のMovie3は[1.3, 0.7, 0.5]

です。推定された評価\hat{r}は[1.4 * 1.3 + 0.4 * 0.7 + 0.5 * -1.2 ] = 1.6となります。なお、算出しなくてもいいのですが2乗誤差はは(8 - 1.6)^2=40.96となりました。

更新処理

誤差を元にp, qの値を更新します。p,qの行列から参照した要素は次のとおりでした。

From p1.40.4-1.2
From q1.30.70.5

新しい特徴値は次の計算式を用いて求めます。

p_i = p_i + 2 \alpha ( r - \hat{r} ) q_i

q_i = q_i + 2 \alpha ( r - \hat{r} ) p_i

ここでαは学習率と呼ばれ、0.1くらいにするケースが多いです。これに習ってpの最初の要素を計算してみると、次のようになります。(p_0は1.4, q_0は1.3を指します)。何故この更新式になるかは[2]を参照ください。

1.4 + 2 \times 0.1 \times ( 8 - 1.6 ) \times 1.3 = 3.064

出た値を用いてp_0の値を更新します。

p3.0640.4-1.2

同様にp, qの残りに対しても更新処理を行います。なお、qの更新式ではpの値も使いますが、既に更新された値を使います。

上記例ではp_0の値は3.064になりましたので、q_0を求める際には、新しい数値を使います。あとは繰り返し処理を繰り返し行い、 すべてのp, q行列を更新していきます。以上となります。

これを学習データを用いて適当に取ってきた値で特徴量行列を更新していきます。

ちなみに本当の評価rがNaN値であれば重みの更新処理をしなくても構いません。(というかしません。)。これがNaN値を考慮したSVDであると言える理由です。

重み更新は、ニューラルネットワークなどではおなじみなのですがStochastic Gradient Descent (SGD):確率的勾配降下法と呼ばれています。ランダムに(確率的に)適当な値を引っ張ってきて、重みを更新している(勾配をもとに、2乗誤差を低くするように降下させている)からです。差分がゼロ(微分がゼロ)になれば、最適解に到達したと言えます。

なぜ特異値分解:SVDと呼ばれるのか

今までの操作のどこが特異値分解なのでしょうか。

特異値分解とは、ある行列をエラーを最小とするように2つに分割する操作でした。今回分割する際に行ったことは2乗誤差が最小になるように分割していく操作です。SVDはもともと最小二乗近似を得る方法とも言えます。 実際に開発者のFunkも自身のブログで次のように述べています。

Singular value decomposition is just a mathematical trick for finding those two smaller matrices which minimize the resulting approximation error–specifically the mean squared error (rather convenient!).

さて、大まかな説明はこれで終わりなのですが、更にいろいろな工夫をすることで精度が高くなります。SVDのかわりにNMFを使うことだったり、今回はマトリックスを分割する際のエラーの計算方法として単純にpqに分けるようにしましたが、実際に精度を出すためには正則化したり、バイアス項を突っ込む必要があります。

さらなる改良

正則化

結局重みは次の式を最小化するということでした。

minimum( p,q ) \sum_{u,i\in K}(r_{ui}-q_i^Tp_u)^2

ここで上記式に正則化をします。(過学習を防ぎ、一般的に使えるようにするためにペナルティ項をおく)

minimum( p,q ) \sum_{u,i\in K}(r_{ui}-q_i^Tp_u)^2 + \lambda (\|q_i\|^2 +\|p_u\|^2)

最初の項目に\lambda (\|q_i\|^2 +\|p_u\|^2)をおいただけです。この意味ですが、1つの映画を酷評したユーザーがいて、その映画以外は評価していなかったとします。すると、このユーザーから他の映画へのすべての評価が非常に低くなります。この正則化をおけば、 例えば q_iに大きな値を与えることを抑制することになります。

バイアス項

推測されるレーティングは次の式でもとまるものでした。

\hat{r} = q_i^Tp_u

ここに3つのバイアスを加えます。

  • \mu 全アイテムの平均レーティング
  • b_i アイテムiの平均レーティングから\mu を引いたもの
  • b_u はユーザーuの平均レーティングから\mu を引いたもの

最終的に、レーティングの式は

\hat{r} = q_i^Tp_u+\mu + b_i + b_u

となります。そして、こちらにも正則化項をいれます。

minimum( p,q ) \sum_{u,i\in K}(r_{ui}-q_i^Tp_u)^2 + \lambda (\|q_i\|^2 +\|p_u\|^2+b_i^2+b_u^2)

これが最終的なSVDの式となります。長くなりましたが、これでMFの説明は終わりとなります。

最後に

その他、NMFやSVD++など、エンジンを変えて更新式を実行したい場合は、次のURLに詳細に書かれています。[1]

もしNMFによる実装方法などを記事化してほしい方がいれば、コメントをいただけますと幸いです。

索引

関連記事

Word2Vec

はじめに

Word2Vecは近年で最も注目された技術の一つです。主にNLP( Natural Language Processing:自然言語処理 )の分野の技術なのですが、広告や商品ページなどの推薦エンジン技術として利用されています。実際に楽天などでは本技術を使っているようです[1]。
コンセプトはとてもシンプルです。今回はWord2Vecがどういう技術かをpythonをもとにnumpyを使ったソースコードの実装例をもとに説明します。(gensimなどのライブラリは使いません!)。実際内部でどのような処理が行われているか参考にしてください。

どのようなモデルか

このような構成をしています。すべて本ブログで解説しますので、読み終わる頃にはよくわかります。

Word2Vecは何か

単語をベクトル化する、これをWord2Vecが行います。単語のベクトル化ができることで、四則演算が使えてしまうのです。。

例えば、仮に単語の「神様」:[5, 1, 3]というベクトルで表した場合、「神様」と似ているベクトルを探すこと(例えばコサイン積をとって1に近いものを探すこと)ができます。他にも「神様」:[5,1,3]へ新しく、例えば「海」:[1,2,3]というベクトルを足し込んだ結果を探すと、「リヴァイアサン」:[6,3,6]がヒットするかもしれません。外積をとって直行する言葉を探すなんてこともできそうですね。

その昔、類似度を探す技術と言えば共起行列を用いて探る方法が一般的でした。文章から単語を抽出してともに出現する単語から共起行列を作成してSVDなどで解析し、特徴量空間にマッピングして類似度を算出するのです。ただ、この方法では行列のサイズが大きくなると計算がおいつかなくなる問題がありました。2013年にGoogleの研究者がWord2Vecを提案し、業界がざわつきました。

単語をベクトル化することは「word embedding」と呼ばれます。

Word2Vecの実装に向けて

Word2Vecのアルゴリズムは大きく2つあり、違いは次のとおりです。

  • Continuous Bag-Of-Words (CBOW)
    • Skip-gramよりも数倍学習が早く、頻出単語に関しては若干の高い精度を誇っている。
  • Continuous Skip-gram (SG)
    • 小さい学習データから珍しい単語やフレーズを上手に表現できる。

2つともに近くの単語( context words )からの確率分布を用いた手法であります。近傍の単語から新しい単語の類推をしていきます。今回はSG( Skip-gram )を説明します。

学習方法

学習データは文章を使います。今回は次のフレーズを元にWord2Vecを説明します。

「Although the world is full of suffering, it is full of the overcoming of it.」
(世界は苦しいことでいっぱいだけれども、それに打ち勝つことでもあふれている。) : マザーテレサ

上記英語フレーズを学習データとします。実践では長い文章を用意します。

さて、Skip-gram では学習にニューラルネットワークを使います。教師データとなるトレーニングデータは単語の近傍の単語(context wordsとよばれる)です。具体的に行きましょう。例えば、先のフレーズの「world」であれば、[Although, the, is, full]がcontext wordsとして学習セットになります。

近傍の単語数はWindow Sizeともよばれ、上記例でのWindow Sizeは5となる

フレーズ内の、すべての単語に対し同様に学習セットを用意します。たとえば、Althoughの近傍の単語context wordsは[the, world]となります。後述しますがovercomingの近傍ワードは[ of,the, of, it ]ですが、重複は除かれるルールになるので [ of,the, it ] となります。

Vocabulary & One-hot Encoding

Vocabulary

具体的な学習データの作り方ですがVocabulary(ボキャブラリ)を構築し、one-hotエンコーディングを実施する必要があります。

「Vocabulary」とは重複のない単語群のデータです。まずは文章を空白区切りします。

[‘Although’, ‘the’, ‘world’, ‘is’, ‘full’, ‘of’, ‘suffering,’, ‘it’, ‘is’, ‘full’, ‘of’, ‘the’, ‘overcoming’, ‘of’, ‘it’]

そして、重複を除きます。

[‘Although’, ‘the’, ‘world’, ‘is’, ‘full’, ‘of’, ‘suffering,’, ‘it’, ‘overcoming’]

‘it’, ‘is’ , ‘full’ , ‘of’ , ‘the’の重複が取り除かれ1つになり、9次元のデータがとれました。これで「Vocabulary」 が完成しました。

One-hot encoding( One hot vector )

続いてone-hotエンコーディングを行います。図を見るとわかりますが、単純に単語があるところを1にし、それ以外を0にするだけのベクトルを作るという意味です。

One-hot vectorとも呼ばれています。さて、以上で下準備は終わりました。

最初にworldのcontext wordsは「Although, the, is, full」と説明しました。このデータはone-hotベクトルで表現すると [list([0,0,1,0,0,0,0,0,0]), list( [ 1,0,0,0,0,0,0,0,0 ], [ 0,1,0,0,0,0,0,0,0 ], [ 0,0,0,1,0,0,0,0,0 ], [ 0,0,0,0,1,0,0,0,0 ] ) ]というペアになります。

Training

学習の流れですが、学習の全体像は次のとおりとなります。

今、例としてHidden層の次元数は10としている。最初の重み行列W1のサイズはしたがって9×10となる。W2も10×9となる

Word2Vecでは、ニューラルネットによる学習において、上記のうち、2つの重みマトリックス(W1, W2)を更新していきます。(その方法は次の章で記載します。)

学習終了後ですが、使われるものは重み行列W1です。実はこれが特徴量行列となっています。例えば、”although”[ 1,0,0,0,0,0,0,0,0]があったとして、これと学習後の重み行列W1をかけ合わせて出てきたものが”although”の特徴量ベクトルです。実際には掛け合わせると言っても one-hotベクトル内で 1が立っている行をW1から抜き出すという操作になります。

さて、では具体的にどのようにWord2Vecでは学習されているか解説します。

Trainig – Forward pass

まず、一番最初のプロセスとして行列W1,W2はランダムな0-1の範囲で初期化をしておきます。そしてニューラルネットのforward関数としては次の関数を使うことになります。

def forward_pass(onehot_input, w1, w2):
    h = np.dot(w1.T, onehot_input)
    u = np.dot(w2.T, h)
    y = self.softmax(u)
    return y, h, u

onehot_inputは先の例で言うところの [0,0,1,0,0,0,0,0,0] (入力単語 worldのone hot表現)です。重み行列W1とonehot_inputでドット積を実行してHiddenベクトルh(隠れ層)が求められます。続いて、重み行列W2とHiddenベクトルhでドット積を実行し、出力ベクトルuを算出しsoftmax関数をかけて予測値ベクトル yを算出します。

結局のところforward_pass関数におけるリターンバリューとしては[予測値ベクトル y、Hiddenベクトルh、出力層ベクトルu]の3つを得ます。 softmax関数により必ず値が0〜1の範囲になります。

def softmax(x):
    e_x = np.exp(x - np.max(x))
    return e_x / e_x.sum(axis=0)

Training- Error

forward_pass関数の結果から予測ベクトルyが返ってきました。このベクトルの値はsoftmax関数を噛ましているので必ず0-1の範囲に収まっています。さて今、次の予測ベクトルyが返ってきたとします。

y = [0.888, 0.801, 0.766, 0.552, 0.659, 0.371, 0.517, 0.087, 0.212]

ここで、出力すべきであった 「although, the, is, full」のone-hotベクトル[ 1,0,0,0,0,0,0,0,0 ], [ 0,1,0,0,0,0,0,0,0 ], [ 0,0,0,1,0,0,0,0,0 ], [ 0,0,0,0,1,0,0,0,0 ] それぞれと 予測ベクトルyの差分を取ります。期待された結果からどれだけ間違えたかを差分として求めるのです。具体的に示します。

予測の確率であるyとone-hot表現された単語との差分を、オレンジ色の枠線内が表している。

こうして出来た、それぞれの単語で求められたエラーベクトルをすべて足し込み1つのエラーベクトルをeを作ります。このエラーベクトルeはBack Propagation処理で利用し、重み行列を更新していきます。

Training- BackPropagation

ニューラルネットワークでおなじみのバックプロパゲーション(誤差逆伝播)を行います。目的はエラーベクトルeを利用して重み行列(W1, W2)を更新することにあります。

具体的な流れは次のとおりです。

  1. Hidden ベクトルhとErrorベクトルeで外積を取り、重み行列w2に対する差分行列dW2を作成します。
  2. 重み行列W2とErrorベクトルeで内積を取り、ベクトルmを作ります。入力ベクトルonehot_inputとベクトルmで外積を取り、重み行列w1に対する差分行列dW1を作成します。

それぞれの重み行列W1, W2から、 それぞれ差分行列dW1 *学習率 ,dW2 *学習率を引き、新しい重み行列を得ます。学習率は1%くらいに設定しておきます。

learning_ratio = 0.01
def backprop( w1, w2, e, h, x ):
    dW2 = np.outer(h, e)
    dW1 = np.outer(x, np.dot(w2, e.T))
    # 重みの更新
    w1 = w1 - (learning_ratio * dW1)
    w2 = w2 - (learning_ratio * dW2)

以上で終わりです。これをぶん回していくことで学習していきます。学習後、W1の重み行列が特徴量行列となっています。

Trainig-Loss

損失関数を定義し、学習進み具合をチェックできます。損失関数は実際に学習作業と関係ありませんが、ぶん回す回数(エポック数)を決める上で、発散したりしていないか、きちんと収束しているかを確認する上で重要となります。

損失関数は2つの部分で構成されています。1つめは出力層のすべての要素の合計、の負の値です(softmaxの前)。 2 つめは、近傍の数であるcontext_wordsの数と、出力層のすべての要素(指数関数の後)の合計のログの乗算です。

loss += -np.sum(u) + len(context) * np.log(np.sum(np.exp(u)))

類似語の探索

特徴量ベクトルは、最初に説明したとおりone-hotベクトルと学習後の重み行列W1とのドット積によりもとまります。特徴量ベクトルを取得したら、後は、四則演算となります。例えば類似ワードは内積値が1日買い物となります。ベクトル同士の四則演算をして、得た結果かから色々と考えてみると面白いかと思います。

参考文献

https://towardsdatascience.com/an-implementation-guide-to-word2vec-using-numpy-and-google-sheets-13445eebd281

おすすめ記事