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

おすすめの記事

最小二乗法による重回帰分析

重回帰分析の値を最小二乗法でえる方法を示す。早速だが、次の式に適用することで目的の係数群が手に入る。

\mathbf{w} = (\mathbf{X}^{T}\mathbf{X})^{-1} \mathbf{X}^{T}\mathbf{y}

上記x, y, wは次の通りの関係を持っている。

y_i=a_{0i} + x_{0i}w_{0i} + x_{1i} w_{1i} + ... x_{ni} w_{ni}+ f

aは切片、fは残差と言われる。ここでyはm個あると仮定する。回帰分析のところで説明したとおり、切片aと重みwベクトルを求めるのが回帰分析の目的となる。最初の\mathbf{X}については次のようになる。

\mathbf{X} = \begin{bmatrix} \mathbf{x}_0 & \mathbf{x}_1 & … &\mathbf{x}_n \end{bmatrix}

\mathbf{x}_0 = \begin{bmatrix} x_{00} \\ x_{01} \\ ... \\ x_{0m} \end{bmatrix}

となっている。

上記ではやや理解がしづらいと思うので、実際に具体例をしめす。なお、他のサイトでは切片を求めない場合もある。当サイトでは、今回は切片を考慮に入れて説明しよう。

LSMを用いた重回帰分析の仕方(計算方法具体例)

具体的に説明のためにあるサイト[2]から取得したデータを用いる

営業所 売上額 広告費 販売員数
A 8 500 6
B 9 500 8
C 13 700 10
D 11 400 13
E 14 800 11
F 17 1200 13
G ? 1300 14

上記で営業所Gの売上高を予想したいというのが今回の目的である。(そのために重みベクトルと切片を求める。)上記の表で例えば営業所Aの売上高は 8 = a_0 + 500 * w_0 + 6 * w_1 ということになり、最小二乗法を使うことで未知の切片のa_0と重みである\mathbf{w}が求まる。

まずは上記表を元にa0, x0, x1のベクトル、並びにyのベクトルを定義する。

import numpy
def main():
    y = numpy.array( [[8, 9, 13, 11, 14, 17]]).T
    a0 = numpy.array( [[ 1, 1, 1, 1, 1, 1]] ).T # 切片が不要な場合はここは不要
    x1 = numpy.array( [[ 500, 500, 700, 400, 800, 1200]] ).T
    x2 = numpy.array( [[ 6, 8, 10, 13, 11, 13]] ).T

a0に関しては全て1にする。切片であるために1以外の数は適切ではない。そして\mathbf{X}は次のように定義される。

X = numpy.hstack( [a0, x1, x2] ) # 切片が不要であれば最初のa0をなくす。
# [[   1  500    6]
# [   1  500    8]
# [   1  700   10]
# [   1  400   13]
# [   1  800   11]
# [   1 1200   13]]

肝心のXは上記のように出力される。なお、切片が不要な場合は最初の「1」がなくなった行列となる。さて、定義できたのであとは最初に示した式通りに書いていく。

XTX = X.T.dot(X) # X^T.X                                                                                                                                                                                                      
invXTX = numpy.linalg.inv( XTX )
result = ( invXTX.dot( X.T ) ).dot( y )
print( result )
# [[1.14814815]
#  [0.00786008]
#  [0.53909465]]

出力値として、a_0=1.14814815, x_0=0.00786008, x_1=0.53909465となった。

この値を元にGの営業所の売上予想は 1.14814815 + 1300 \times 0.00786008 + 14 \times 0.53909465=18.9135772となる。

参考文献

  1. https://k-san.link/linear-regression-01/
  2. https://istat.co.jp/ta_commentary/multiple
  3. https://datachemeng.com/wp-content/uploads/ordinaryleastsquares.pdf

おすすめのサイト

敵対的生成ネットワーク(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

次におすすめの記事

数量化理論Ⅳ類(MDS)

数量化理論Ⅳ類はMDS,多次元尺度構成法とも呼ばれます。対象物の類似度を測る手法です。

あるアイテムが似ている、似ていないというのを平面上にプロットすることができます。平面上だけでなくても、直線、或いは3次元上でプロットできますが、多くは視覚的にわかりやすい2次元にプロットする例が多いです。なお、「似ている程度」「似 ていない程度」の量のことを数量化理論Ⅳ類では親近性と呼びます。

MDSの説明ですが、以下のドキュメントが非常にわかりやすく書かれています。導出が気になった方は、是非次のドキュメントに目を通してください。

http://www.page.sannet.ne.jp/yo-skmt/MultiEx/No13-15MU-Text02.pdf

女性ファッション雑誌をMDSで可視化

MDSの利用シーンとしての具体例を考えます。例えば女性専門雑誌がいくつかあるとします(VIVI, an anなど)。女性向けファッション雑誌の内容は男性からするとどれも一緒に見えますが、女性から見ればファッションの種類によって雑誌がかわります。では具体的にどの雑誌とどの雑誌が似ているのか、という事を男性が知りたいときにはどうすればいいのでしょうか。(男性は例え本の中身を見てもさっぱりですよね?)

まさにこんなときがMDSの出番と言えるでしょう。MDSを使えば次のようにマッピングできます。

上記画像は https://u-site.jp/research/methods/mds/ より。

雑誌の中身を見て、これとこれが似ているなど判断しなくてもMDSによりわかりやすく可視化することができます。

その他にも、例えばM1グランプリの採点から、採点者の傾向や、お笑い芸人のタイプなどを可視化出来ます。採点表はネットに公開されているので、興味があったらMDSで実験してみたら面白いかもしれません。

MDSの計算方法

対象物間に「距離」という概念を持ち込む必要があります。対象物間どうしの「距離」の概念を何らかの方法で定義できれば、 固有値分解を実施することでMDSが完了します。距離とは何か、ということを疑問に思った方は距離の公理を参照ください。大抵の場合は馴染み深い距離(ユークリッド距離)を利用するケースが多いです。

ちなみに、どのような行列に対しての固有値分解(あるいは特異値分解)をすることで分析手法の名前が変わってきます[2]

  1. 相関行列の固有値分解⇒主成分分析or因子分析 (数量化理論III類)
  2. 分散比行列の固有値分解 ⇒ 判別分析 (数量化理論II類)
  3. 頻度行列の特異値分解 ⇒コレスポンデンス分析(数量化理論III類)
  4. 距離行列の固有値分解 ⇒ MDS (数量化理論IV類)

MDSは対象間同士の距離行列さえ求めてしまえば、あとはやる(主成分分解する)だけで可視化できるというのはある意味興味深いです。

距離行列の作成

実際の例で計算をして見ます。今、A,B,Cの三人がいたとして、それぞれの互いにどう思っているかを評価してもらいます。(親近性の調査)

Aは Bは C は
Aのこと NaN 3 3
B のこと 4 NaN 3
C のこと 3 2 NaN

上記のように、互いに評価をしています。なおこの行列を親近性行列と呼びます。

MDSとは、例えば3人を親密な人は距離が近く、そうでない人同士が遠くなるように位置を求めることです。2次元でも、3次元でも考えられますが、計算できる次元数は{対象者−1}次元となります。なお、親近性行列の要素は e_{11}, e_{12}, e_{13}, e_{21} ... e_{32}, e_{33}のように、 e_{ij}として表すことにします。

1次元の直線でA,B,Cの親近性を表現すること考えてみます。A, B, Cのそれぞれの位置をx1, x2, x3とします。ここで、位置x1, x2, x3の制約条件を次のとおりに定めます。

  • x1, x2, x3の平均は0
  • x1, x2, x3の分散は1

そして関数Qというものを定義します。関数Qは次のような定義です

m=特定の2人の親近性\times 特定の 2人の距離の2乗

つまり次のようになります。

m=e_{ij}\times (x_{i} - x_{j})^2

全員分のmの総和にマイナスしたものをQとして定義します。

Q=-\sum{ m_k }

Qの最大化を行うと、親近性の大きい個体の距離は短く、親近性の小さい個体の距離がなくなるようになります。なお、距離の定義は今回は線分で見るのでユークリッド距離を使いますが対象により変わります。要は距離の公理の「距離」が定義できるものであれば何でも良いです。

さてこれらを用いて計算ですが、細かい導出が坂元保秀教授により大変素晴らしくまとめられています。[1]

数式が多くなるので、結論を書きますと親近性行列から行列Hを定義します。行列Hとはh_{12} = e_{12} + e_{21}という形で、対角線同士を足し合わせる行列となります。対角線の要素[ latex ] e_{11}, e_{22}, e_{33} [/latex]については、e_{11} = -(e_{12}+e_{13}) \times 2 のように、自分意外の行を全て足しこみ、二倍してマイナスにした数字です。以上の操作を図にすると次のとおりです。

行列Hの作り方。親近性行列と、その天地を足す。加えて、親近性行列の行をすべて足しこんだものを2倍してマイナスをかけたものを対角の要素とする

行列Hがでたあとは、固有値分解をすることでx1,x2,x3が算出されます。いま、Pythonを用いて行列Hを固有値分解してみましょう。

import numpy as np
import math

a = [-12, 7, 6]
b = [7, -14, 5]
c = [ 6, 5, -5]
mat = np.array( [ a, b, c ] )
e_value, e_mat = np.linalg.eig( mat )
x1, x2, x3 = e_mat.T[ 0 ]
print x1, x2, x3

e_valueは固有値、e_matには固有ベクトルが入っています。e_matに格納されている値がまさにx1, x2, x3となります。それぞれの値は[-0.521333010475 -0.44553760869 -0.727810505167]となっています。

ここで先程の制約条件を思い出してください。平均が0、分散が1ということで定義しました。この条件が合っていることが確認できるかと思います。(厳密には要素数が3ですので、x1, x2, x3にsqrt(3)をそれぞれ掛けた値で分散が1になるのですが、スケーリングするということだけですので気にしなくて構いません。)

もし直線でなく平面でプロットしたい場合は固有ベクトルの1行目と2行目を使います。今一行目でx1, x2, x3としましたが、2行目をy1, y2, y3としてプロットすればよいだけです。3次元の際は、3行目をzとして利用します。

参考文献

[1] http://www.page.sannet.ne.jp/yo-skmt/MultiEx/No13-15MU-Text02.pdf

[2]https://www.nikkei-r.co.jp/glossary/id=1605

おすすめ記事

ページランク( Page Rank )

はじめに

ページランクの仕組み 、アルゴリズムを意外と知らない人は多いと思います。ページランクアルゴリズムはGoogleの創業者であるLawrence Page とSergey Brinにより考案されました。今や世界を支えていると言われる検索エンジンアルゴリズムはとてもシンプルな式でした。

PR(webPage_x) = (1-d) + d \times (\frac{ PR(webPage_1)}{Count(webPage_1)} + ... + \frac{ PR(webPage_n)}{ Count(webPage_n)})

PR()はページランクを表します。最初は定まっていないので初期値として全てのページに適当な値を入れますが1を入れるケースが多いです。

dは減衰率でオリジナルの論文では0.85として設定されています。

Count()はWebページが持つリンクの数です。たとえばWebPage1がリンクを10個持っていればC( WebPage1 ) は10となります。

上記式をみると、自分のページランクをリンク数で除算し分け与えているという意味となります。こんな単純なアルゴリズムから世界を変えるほどの技術となっているのは本当に驚きです。

さて実際の計算しページランクがどのように更新されるのか見てみます。

Page Rankを計算してみる

以下のようなページがあったと仮定します。

AはB,C,Dへバックリンクを貼る、BはDへ、CはA, Dへ。DはA,Cにリンクを貼る。それぞれ、Count(A) = 3, Count( B ) = 1, Count( C ) = 2, Count( D ) = 2となる。

今、4つのウェブページがあり、それぞれリンクを張っています。Aは3つ、Bは1つ、Cは2つ、Dは2つリンクを張っています。このリンクを貼られたという関係を表すと次のようになります

Aから B から C から D から
Aへ 0 0 1 1
Bへ 1 0 0 0
Cへ 1 0 0 1
Dへ 1 1 1 0

例えば、Aは(B,C,D)にリンクを張っていますので(B,C,D)にそれぞれ1を立てました。

続いて確率モデルの表を先の表から作成します。今Aのリンク数、Count(A)は3です。Aが他のサイト(B,C,D)への分配数はその逆数である1/3ずつが分配されます。上の表をそれぞれのリンク数で除算し,作成された表は次のとおりです。

Aから B から C から D から
Aへ 0 0 \frac{1}{2} \frac{1}{2}
Bへ \frac{1}{3} 0 0 0
Cへ \frac{1}{3} 0 0 \frac{1}{2}
Dへ \frac{1}{3} 1 \frac{1}{2} 0

行列の縦軸に注目すると、合計が必ず1になっていることに気づくと思います。また、マイナスの値が入っておりません。一般的にこうした行列のことを確率行列( column-stochastic matrix)と呼びます。

ページランクベクトルを用意し、先の式に適用していくとページランクが出てきます。今、初期値のページランクを1とします。

PR(A) = 1, PR(B) = 1, PR(C)=1, PR(D)=1

準備がととのったのでページランクを計算します。わかりやすさを重視するために愚直なコード、いわゆるベタ書きコードで表します。実際のプログラムでは、100万ページがあってもエレガントに解けるようなソースコードの書き方を目指してください。

def main():
    # PageRank初期値                                                                                                                                                                                                              
    PR = {'A': 1.0, 'B': 1.0, 'C':1.0, 'D': 1.0 }
    # リンク数の逆数(確率)                                                                                                                                                                                                      
    Prob = {'A': 1/3.0, 'B': 1, 'C':1/2.0, 'D': 1/2.0 }
    # 減衰率                                                                                                                                                                                                                      
    d = 0.85
    # 10回回す                                                                                                                                                                                                                    
    for i in range( 10 ):
        new_PR_A = PR[ 'C' ] * Prob[ 'C' ] + PR[ 'D' ] * Prob[ 'D' ]
        new_PR_B = PR[ 'A' ] * Prob[ 'A' ]
        new_PR_C = PR[ 'A' ] * Prob[ 'A' ] + PR[ 'D' ] * Prob[ 'D' ]
        new_PR_D = PR[ 'A' ] * Prob[ 'A' ] + PR[ 'B' ] * Prob[ 'B' ] + PR[ 'C' ] * Prob[ 'C' ]

        PR[ 'A' ] = (1-d) + d * new_PR_A
        PR[ 'B' ] = (1-d) + d * new_PR_B
        PR[ 'C' ] = (1-d) + d * new_PR_C
        PR[ 'D' ] = (1-d) + d * new_PR_D
    # 新しい値を出力
        print( "%.4f %.4f %.4f %.4f" % (PR[ 'A' ], PR[ 'B' ], PR[ 'C' ], PR[ 'D' ] ) )
if __name__ == '__main__':
    main()

10回ぶん回した結果は次のとおりです。段々と値が安定しているのがわかります。

ページ数や構造がシンプルな例でページランクの計算方法をみてみましたが、実装がシンプルであり、とてもわかり易いアルゴリズムであると思います。ただ、世の中のモデルがこのように単純な問題であるとは限りません。例えばリンクを貼られていないページがあるとどのような問題がでてくるのでしょうか。

Rank Sinks問題

あるページがリンクを全く持っていない問題のことをRank sinks 問題と呼びます。例えば次のようなケースの場合です。

Dが最後になっている場合、Rank Sinksと言われています。流しのシンクのようになっているからです。Dはリンクを持っていないので移動する確率は0であり、周りからページランクをもらうだけです。Dは必然とページランクが上がる一方で、他に配る宛もなく、これは公平ではありません。

この場合はシンクDの移動確率は、全ページ数の逆数を利用することで問題解決を図ります。今(A,B,C,D)と総ページ数4ですので1/4がDの移動確率として定義されます。

Aから B から C から D から
Aへ 0 0 \frac{1}{2} \frac{1}{4}
Bへ \frac{1}{3} 0 0 \frac{1}{4}
Cへ \frac{1}{3} 0 0 \frac{1}{4}
Dへ \frac{1}{3} 1 \frac{1}{2} \frac{1}{4}

全ページ数の逆数である理由は、最後にたどり着いたページからランダムに別のページに行く確率は等確率である、という仮定に基づいています。

注意(ページランクのもう一つの式

ページランクの式としてはNで割る場合もあります。

PR(A) = \frac{(1-d)}{N} + d \times (\frac{ PR(webPage_1)}{Count(webPage_1)} + ... + \frac{ PR(webPage_n)}{ Count(webPage_n)})

最初の項目でNという項目が入っただけです。Nはページ数を表しており、先の例であれば4が入ります。この修正された式の理由は全てのページランクの合計は1である、つまり確率モデルとして正しい形を求めた為です。従って本質的な意味の違いはありません

ところで、PageRankのアルゴリズムはマルコフ連鎖であるといわます。マルコフ連鎖とはざっくりいうと、次の状態は前の状態からのみ決定されるというものです。ページランクのアルゴリズムを観ると分かる通り、一つ前の状態からしか次のページランクは影響をうけません。そのため、マルコフ連鎖を仮定してという言い方がされます。

ページランクの仕組みは理解できましたでしょうか?当サイトが皆さんの知識の工場に役立ってもらえれば幸いです。

参考サイト

関連記事

強化学習( 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-table Action 0 Action 1 ….. Action N
State 0 0 0 …. 0
State 1 0 0 …. 0
…. 0 0 …. 0
State N 0 0 …. 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通中を受信したとします。迷惑メールの中で次の単語「バイアグラ」、「限定品」、「機械学習」という単語が含まれた表、頻度表を作成します。次のようになりました。

バイアグラ 限定品 機械学習
回数 7 17 0

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

バイアグラ 限定品 機械学習
YES 7/30 17/30 0/30
NO 23/30 13/30 30/30

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

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

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

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

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

バイアグラ 限定品 機械学習
YES 1/100 1/100 7/100
NO 99/100 29/100 23/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/

次のおすすめ記事