ゼロから作るDeep Learning3 フレームワーク編を読む その⑤ステップ37
はじめに
以下の記事シリーズの続きです。今までは複数ステップで一記事にしていたのですが、今回大いにつまずいたので1ステップで1記事使います。 cha-kabu.hatenablog.com
「ステップ37 テンソルを扱う」の概要
これまで作成してきたDeZeroが、スカラだけでなくテンソルの演算にも実は対応していることの説明です。説明のステップですので新しい実装は無い(コードを使った説明はありますが、後続のステップの実装を先取りして使う)のですが、発展的内容として今のDeZeroでテンソルを使用した時の逆伝播について数式で説明されています。
※「あくまで今のDeZeroに実装された機能に限った逆伝播の話で、テンソルの逆伝播全般の説明ではない」を理解するのに随分時間がかかりました。そう書いてあるんですけどね。
基本的な関数の微分や偏微分はそこまで困らない程度には慣れてきたのですが、ベクトルの微分 / ベクトルでの微分については騙しだましで来てしまったので大いにつまずきました…ということで、書籍にの内容というよりかその前段の基本的なところまとめておきたいと思います。
本記事で扱うのはテンソルを扱った場合の逆伝播計算に必要となる微分計算までで、実際に逆伝播したらどうなるの?はステップ41あたりの記事の時にまとめたいと思います。
なお、書籍によると
ここでは「ベクトル」に限定して話を進めます。ただし、この節で得られる結論(理論)は「テンソル(n階テンソル)」の場合にも、そのまま適用できます。
とのことなので、基本的にはベクトル~せいぜい行列までを扱いたいと思います。また、線形代数の基礎や(偏)微分については自分がつまずいたところだけのまとめになります。
※以下の数式一つ一つは外部情報参考にしたので合っているかと思いますが、説明の日本語や数式の関係性は「こういうことかな?」と自己流に解釈しながら記述したため間違い多発している可能性が高いです。
そもそもofそもそも論
そもそも分野がなんのか
地味にこれが一番困りました…テンソルを扱うので当然(線形)代数学だろうと思ったのですが、自分が理解できる情報だとベクトルや行列をあれやこれやするのが主題で微分には触れられていないか、触れられていても説明があっさりし過ぎていて「良く分からん」となりました。
線形代数も関わる内容ですが、それよりも解析学の中の、特にベクトル解析と呼ばれる分野の話みたいです(最初から最後までドンピシャの情報には当たらなかったので、実際には様々な分野が複合しているのでしょうが)。ネットの検索時や書籍を探す際、そっちの分野をあたった方が望む情報に辿り着きやすいかも知れません。
関数についての解釈を(自分の中で)広げておく
関数と言えば、といった感じで、「何かしらの入力を与えるとそれを変換して出力する装置」をイメージしていました。
この解釈もそう間違ってはいないと思うのですが、この時もし裏で「スカラを入力してスカラを出力する装置」というイメージも持ってしまっているのであれば、考えを改めなければなりません。
例えば以下のベクトルの内積計算を考えます。
この計算結果はで、スカラとなります。別の見方をすると、「ベクトルを入力に受け取ってスカラを出力する関数」が存在するということです。具体的に今回の例でいうと「(二次元)ベクトルを入力に受け取って、との内積を計算=スカラにして出力する」関数が作れます。
ここの頭を切り替えておかないと、例えば以下の様な記号の理解が難しくなります。
※は細字でスカラ、は太字でベクトルを表していることに注意してください。
以前の自分はこれを見ても「なんかよくわからんけどとりあえず偏微分してる」以上の情報を読み取ることができませんでした。しかし、頭の切り替えを行っていれば「入力にはベクトルを受け取って、スカラを出力する関数を偏微分しようとしているんだな」と把握できるようになります。
また、このイメージができると以下のイメージもできるようになります。
下線の情報が把握できた時点で図の黒字部分の情報がイメージできていることになります。そこから、「ベクトルに何かするとスカラになる処理と言えばベクトルどうしの内積では?」だと気付ければ、赤字部分を補完し「ベクトルどうしの内積計算を微分しようとしている」という部分もイメージできる様になります。
※ベクトルを入力に受け取ってスカラを出力する関数は他にも存在するかと思います。あくまで「可能性をイメージできる」というだけで、「決定できる」ではありません。
「そんなの当たり前じゃないか」(もしくは「全然違うぞ」)と思われるかも知れませんが、自分にとってはこの切り替えは以降の内容を理解するのにとても重要でした…
スカラ値関数とベクトル値関数
上述の気付きを得た時、「天才かも知れない」と思ったのですが、「ベクトルの関数」というのは解析学では初歩中の初歩の内容の様です。先ほどの例は出力がスカラだったので(多変数)スカラ値関数なのですが、出力がベクトルとなる関数には「ベクトル値関数」とちゃんと名前がついているそうです。
詳しくはこちらのサイト様をご確認ください。ちょっと考えればそういうものがあるとは気付くのですが、関数という分野とベクトルという分野が頭の中で融合しているのといないのでは以降の内容の理解力の差が激しいです。
本題
目次的なもの
本記事の最終目標は「ベクトル(テンソル)を使った逆伝播理解のための前知識取得」です。つまり、ゆくゆくは「計算グラフのどこかで入出力の片方あるいは両方がベクトルになる」場合を考えることになります。例えば以下の様なグラフです。
この逆伝播を理解するために、「ベクトル"を"微分する / ベクトル"で"微分する」方法について学ぶ必要がありますが、主語と目的語の関係によって計算が別物になることに気を付けて下さい。「スカラをベクトルで微分する」と「ベクトルをベクトルで微分する」では作法が異なります。(一般化した定義は同じなのでしょうが…数学素人には別物に見えます。)
「ベクトル"を"微分する / ベクトル"で"微分する」方法について理解するには、以下の順序で学ぶと個人的には理解しやすかったので、この順序に沿ってまとめて行きます。
【ベクトルをスカラで微分する】ベクトル値関数のスカラ微分の定義
高校で習った微分の定義と見た目は同じなので、素人でもそこまで混乱する内容ではありません。参考にしたのはこちらの名古屋大学の講義ノートです。
ベクトルが変数の関数としたとき、以下の様に定義されます。 ※という表記は数学的に正しいのか分かりませんが…がの関数だということを分かりやすくしたかったのと、右辺の表記と揃える意味で付けています。が意味する様に「が変数のという名前の関数」ではありませんのでご注意ください。
結局「が少しだけ変化したときにがどれだけ変化するか」です。成分で書くとより分かりやすく、が三次元ベクトルだとすると、以下の様にも表せられます。
どの様な計算を行えば良いかは書いてある通りでベクトルの各要素についてで微分すれば良いです。そう考えると当たり前ですが、ベクトルをスカラで微分したものの形状は、ベクトル(出力)と変わっていない(今回でいうと3次元ベクトル→3次元ベクトル)点を覚えておいてください。
続いてスカラをベクトルで微分するパターンについてなのですが、少し脱線(?)します。この全微分、次にまとめているgradと密接に関わっており、それらが分かっていると「ベクトルで微分」の意味合いが捉えやすくなるのでお勧めです。
全微分はこちらのYoutube動画が分かりやすかったです。例えばの全微分は以下で定義されるものです。
右辺は数ⅡBで止まっている身からするとパッと見てもただの記号にしか見えませんが、日本語にすれば難しくはありません。「のでの偏微分×方向の変化量 + での偏微分×方向の変化量」です。もっと一般化して書くと以下の様になります。
具体的なイメージは是非紹介した動画で掴んで頂ければと思いますが、イメージを強めるために理解しておきたいのは「左辺と右辺が表しているのはどっちもただのスカラ」ということです。やらなんやらが出てくるとちょっと身構えてしまいますが、要はこいつも1とか100とかのただの数値です。
がただの数値ということの補足として、(数学力の)次元が低い話をします。
という文字を見て何を思い浮かべるでしょうか。自分は平面上のが浮かびます。では、という文字を見たらどうでしょう。前までの自分はここで何もイメージできなくなっていました。あえて言えば「うわ~文字いっぱいの良く分からない数式が出てきそう」くらいです。
この気持ちを次の様に切り替えることが自分にとっては大事でした。からイメージできる「が決まったらの値が決まる何か」と同様、も「が決まったらの値が決まる何か」でしかない、というイメージです。
つまりなら、ならと、変数とは言え極論ただの数値を表しているに過ぎません。の括弧の中に何文字入っていようと、括弧の中には書かれていない文字の軸の値を表しているだけです。括弧の中が1文字なら2次元、2文字なら3次元をイメージし、3文字なら4次元…ここから先はイメージできませんが、とにかく大事なのは「括弧の中の文字の値が決まると、括弧の中にない文字の値が決まる」というイメージです。
改めての全微分の式を見ています。
は微分の式で良く見る通り変化量です。何の変化量かというと、でもでもない別の何かー何かは良く分からないけど、とりあえずとの値が決まれば値が決まる何か―です。何でも良いのですがここではとしましょう。つまりです。
右辺は先ほど説明した通りですが、それぞれの偏微分×変化量の和です。「偏微分=各要素が個別にちょっと動いたらはどれだけ変化するか」に「変化量=各要素がどれだけ変化したか」を掛けて和をとっているので、上の数式は「もどっちもちょっとだけ変化したら、合わせてがどれだけ変化するか」を表しています。図で具体的にみるとこんな↓感じです。
難しいのは、みたいな形で数式が与えられていればとの関係がグラフに書けるのでが変化したときにがどんな感じに変化するかを直感的に理解することができます。しかし大学数学教育ではそんな優しいことは稀なようで、「とは関係していることだけは分かってるけどどんな関係性かは知らないよ」という感じですので、形から入ることができません。また更に、「に関係する何かがあるよ」という感じで関係する何かの名前すら教えてくれません。
この辺り全然イメージができず長いこと苦しんだのですが、少しずつ数式が読める様になるにつれて頭の中でなんとなーく数式が何を表しているのか分かる様になってきました。まだまだせいぜい大学1年次?くらいの数式しかイメージできませんが、慣れていきたいと思います…
【スカラをベクトルで微分する】grad
脱線が長かったですが話を元に戻して続いてgradです。後の節でも改めてまとめますが、先にネタバレしてしまうとここでやることは「ベクトルで微分」そのものです。gradについての詳細は相変わらずヨビノリさんの動画が分かりやすかったのでそちらご確認ください。講義中に出てくる難しい言葉テイラー展開の講義動画もあります。またもし「内積って数式は知ってるけど結局なんだっけ…」となったら「内積とは」で調べると素晴らしく分かりやすい説明がいっぱい出てきますので是非。
動画の概要をまとめます。gradはスカラ場に定義される演算子で、スカラ場のイメージとしては気温が該当するとのこと。上下左右前後の空間での位置が決まれば一つの気温が定まる様に、のが定まればとは別のひとつのスカラ値が定まるような場をスカラ場と呼びます。このとき、
はの変化が最大となる向きを表します。
出力であるスカラを要素をもつベクトルで微分していると考えられるので、「スカラのベクトル微分」そのものですね。
ここからしばらく、残念ながら自分の中でも全然腹落ちしていない超解釈を記載します。自分でも「間違ってそう」と思っているので、変な先入観を入れたくない場合は次の「全微分とgradの関係」まで飛ばしてもらった方が良いかも知れません。いちいち「違うかもしれない」と触れるのも文章が長くなるので、断定的には記していますが全然違うことを記載しているかもしれません。
自分の理解力が低く間違った解釈をしているかもしれない中引用するのも作者の方に申し訳ないのですが、こちらの九州大学の講義ノートとこちらのサイト様を参考にさせて頂きました。
さて、gradはスカラ場に定義されるとしての三次元空間に限定して考えていました。「せっかくだからn次元空間でも同じことできないの?」と思いますが、答えはイエスです。ここで注目したいのは「スカラ場」というものと、参考動画には出てこなかった「座標系」という概念についてです。
(数学に強い人は違うのでしょうが私は)スカラ場の説明を聞くとどうしても軸がまっすぐに直交して軸となっている空間をイメージしてしまいます。そうすると3つの要素が決まると一点が定まってしまうので、求めるスカラ値を除くと最大でも3つの要素についてしか考えられない気がします。
今考えている様な、昔から慣れ親しんだ直交するまっすぐな座標を使う方法は「デカルト座標」と言い、わざわざ名前が付いていることからお察しの通り、実は座標の取り方=点を一意に定める方法は他にもあります。我々が空間をイメージして何をしたいかというと極論は点(もしくはその集合である線とか面とか立体とか)の位置を一意に表すことで、方法は何でも良いのです。
デカルト座標に慣れてしまっている自分としてはもうその存在の意味すら分からないのですが、「軸は直交していなくても良いし、なんなら曲線でも良いでしょ」とでも言わんとばかりの座標も存在します。
こうなってくると軸は無限に引けますし、スカラ場を三次元に限定して考える必要もなく、4つ以上の要素についても同様に考えることができます。4個でも10個でも100個でも、とにかくそれらの要素が決まったときにそれらとは別のひとつのスカラ値が決まるのであれば、それはスカラ場と呼べそうです。
つまり先ほどの例を改変するとに対して、
も同様に定義できるのです。
※特に言及せず行ベクトルを「これがgradだ」という情報があったり、「転置したものをgrad(勾配ベクトル)と呼ぶ」とわざわざ指定した情報があったりと、転置が必須なのか分かりませんでした…とりあえずここでは参考動画に合わせて行ベクトルで書きます。
※この節は前までの節と同じことを言ってる部分が多いですが、関係性を確認するという意味で改めて書いています。迷子になりませぬよう…
全微分の定義式をおさらいします。
要素どうしの積の和ですので、考えて見ればベクトルの内積で表せそうです。すなわち、
と変形でき、はgradfとまったく同じです。つまり、全微分はの変化が最大となる向きであるgradとその時どれくらい変化するかに分解することができます。
更に、方向だけに注目してー方向ベクトルのノルムを1として―内積を別の形で表すと、
であり、これが最も大きくなるのはのときです。
機械学習や最適化の文脈ではこの方向だけ()が大事です。最急降下法を思い出して欲しいのですが、考えるのは「どっちに行けば一番値が下がるか」だけです。そっちの方向に進んだときに「どれだけ値が下がるか」には注目していません。これは、変化の大きさは現在位置に左右されるからです。(同じ微小量移動した時、一気に値が減るところもあれば、少しずつしか変わらないところもあるが、とにかくその点からは最急降下方向に進むしかない。)
【スカラをベクトルで微分する】スカラ値関数のベクトル微分の定義
gradそのものです。対象となるスカラはそのままスカラ場で求まるアウトプット、微分するベクトルには複数の要素をベクトルとしてまとめただけです。
ベクトルを変数にとり、出力がスカラ値となるスカラ値関数をについて微分すると、
は先ほど記載したようにやなどと表現されることがありますが同じ意味です。またgradの他、勾配ベクトルと呼ばれます。
ここで注目しておきたいのは、スカラをベクトルで微分したものの形状は、ベクトル(入力)と変わっていないという点です。同時に、「ベクトルをスカラで微分」した結果は出力であるベクトルと同じ形状だったことを思いだしておいてください。
【ベクトルをベクトルで微分する】べクトル値関数のベクトル微分の定義
スカラ値関数のベクトル微分と似た形になります。ベクトルを変数にとり、出力がベクトル値となるベクトル値関数のについて微分の微分は以下の式で定義されます。
出力との両方がベクトルの場合、その微分は上記の様に行列の形になります。この行列はヤコビ行列と呼ばれます。
【ベクトルをベクトルで微分する】ヤコビ行列とgradの関係
gradの式を思い出します。
これとヤコビ行列を見比べると、ヤコビ行列の行方向成分がgradだと分かりますので、ヤコビ行列は以下の様にgradが複数個縦に並んだ形で書くこともできます。
すなわち、gradはのヤコビ行列とも言えます。
また、そもそもgradが何だったかを思い出すと、「スカラ値の変化が最大となる向き」を表すものでした。ヤコビ行列もそれに対応しており、「ベクトル値の変化が最大となる向き」を表していると言えます。なぜ行列がベクトルの変化に関係するのかがピンとこない場合は、こちらのヨビノリさんの動画をご確認ください。
最後に
以上で一応目標としていた「ベクトル"を"微分する / ベクトル"で"微分する」まではたどり着けました。本当はここまで学んでも書籍のステップ37の補足の入口に立ったくらいで、このあと「要素ごとの演算だと仮定すると~ヤコビ行列は対角行列で~」と本題が続くのですが、それはあくまでこれまでDeZeroに実装した関数に対する話でこれから実装する関数の逆伝播にはまた別の要素が出てくるのでその時にまとめたいと思います。
書籍の「要素ごとの演算だとヤコビ行列は対角行列」については英語ですがこちらが参考になるかと思います(日本語の説明見つからなかった)。この内容全然身についてはいないのですがもう疲れたので次に進みます…
ゼロから作るDeep Learning3 フレームワーク編を読む その④ステップ25~36
はじめに
以下のシリーズの続きです。 cha-kabu.hatenablog.com cha-kabu.hatenablog.com cha-kabu.hatenablog.com
本編
ステップ25~26
ステップ25では計算グラフを可視化するためにGraphvizを使ってDOT言語でグラフを記述する練習を行い、ステップ26でDeZeroでも可視化できるようにします。
と言ってもDeZeroが実際にDOT言語を扱っているのではなく「VariableやFunctionの各種インスタンスの持つ情報からDOT言語の記法に沿った文字列を作成し、dotコマンドにそれを投げて画像として保存、表示」しています。
GraphvizはJupyter Notebook上でも使えるライブラリがあり、そのイメージで直接動かすのかと思っていたので少し混乱しました。
ステップ27~36について
高階微分に関する話が続きます。記載されていることの理解は難しくないのですが、読んでいる最中も読み終わった今も、いまいちここの内容がどうDeep Learningに関わってくるのか分かっていません。ステップ36で
ディープラーニングに関連した用途でdouble backpropを使う研究はいくつもあります。
とあるのですが、自分のレベルでは全然出会っておらずイメージが湧かないので、とりあえず「ふむふむ」くらいで読み進めてしまったので全然ポイントをまとめられていないかも知れません。。
ステップ27 テイラー展開の微分
数学の知識がほぼ数ⅡBで止まっているので三角関数の微分とテイラー展開はちょっと面を喰らいましたが記載されていることは難しくありません。
テイラー展開は数式がどういう計算しているのかは分かったのですが、なぜこれで近似できるかまではイメージできなかったので以下の動画で理解を深めました。
【大学数学】テイラー展開の気持ち【解析学】 by 予備校のノリで学ぶ「大学の数学・物理」
また、書籍に載っているsin関数をテイラー展開した計算グラフがxとy以外のVariableインスタンス名が表示されていなくて計算過程を追いづらかったので、理解を深めるついでに少し改変したグラフを作成しました。左が書籍のもの、右が自作のものです。
何故か同じ層内でノードの位置関係が変わってしまうのだけ修正できませんでしたが、計算過程には変化ないので良しとしましょう…できたときちょっと嬉しかったのでこれを作るまでにやったことを備忘録として記載しておきます。
my_sin関数の修正
my_sin関数とは、書籍で作成するsin関数をマクローリン展開する関数のことです。以下が修正したものですが、後で紹介する他の修正も行わないとエラーになります。
def my_sin(x, threshold=0.0001): y = Variable(np.array(0)) y.name = "y_0" for i in range(100000): j = i j = 2 * j + 1 j = Variable(np.array(j)) d = x ** j c = (-1) ** i / math.factorial(2 * i + 1) c = Variable(np.array(c)) t = c * d y = y + t x.name = "x" j.name = "2*" + str(i) + "+1" c.name = "c_" + str(i+1) d.name = "d_" + str(i+1) t.name = "t_" + str(i+1) y.name = "y_" + str(i+1) if abs(t.data) < threshold: break return y
修正のポイントは以下です。
- いくつかの変数を手動でVariableインスタンス化
- 計算グラフ上に表現できるノードはVariableもしくはFunctionインスタンスに限られます。ここで、「ステップ20~で計算時にVariableでなかったら自動的に変換するようにしたから手動でやる必要ないのでは?」と思いますが、その計算が行われた段階で自動的にインスタンスができる=ノードの名前は全部Noneで、どのインスタンスの名称を変えればいいのか分からないので明示的に指定します。
- おそらくインスタンス変数を辿っていけばこうしないのでもできるのですが、ややこしくて諦めました…
- これにより、書籍のコードではx, y(と実はtも可能)にしか名前がついていませんでしたが、他のノードにも名前を付けられるようになります。
- 手動インスタンス化するタイミングも重要で、自動でインスタンス化される前(ノードができてしまう前)に行う必要があります。
- 後程説明するdの計算過程を明示するためにiもVariableに変換したかったのですが、そうしてしまうとmath.factorial()などのVariableを受け取る想定をしていない関数でエラーが起きてしまうのでjというコピーを作成して使い分けています。
- 計算グラフ上に表現できるノードはVariableもしくはFunctionインスタンスに限られます。ここで、「ステップ20~で計算時にVariableでなかったら自動的に変換するようにしたから手動でやる必要ないのでは?」と思いますが、その計算が行われた段階で自動的にインスタンスができる=ノードの名前は全部Noneで、どのインスタンスの名称を変えればいいのか分からないので明示的に指定します。
- コードを細分化
- 具体的にはt = c * dと表せるような変数dを作成しています。
- これも目的は手動インスタンス化同様でノードに名前を付けたいからです。
- forループの中で名前を付けて、名称で何ループ目の計算なのか分かる様に
Powクラスの修正
一層目の計算でがノードとして表現されています。これはがVariableになっていないとできませんが、Variableになっているとpowを通せません(powはVariableと普通の数値の入力を想定していましたが、今はxもも両方Variableで入力したい)。
これを計算されるためPowクラスとその初期化用関数を以下の様に変更しています。コメント部分が元々のコードです。ただ、この変更が今後どういったエラーを起こすか分からなかったので、対応後元に戻しています。
class Pow(Function): # def __init__(self, c): # 削除 # self.c = c # 削除 def forward(self, x0, x1): y = x0 ** x1 # y = x ** self.c return y # backwardは今回使用しないので変更なし def pow(x0, x1): # def pow(x, c): return Pow()(x0,x1) # return Pow(c)(x)
DOTファイルから直接色を調整する
ノードの色が全部同じだとどれがどの世代の計算か分かりづらかったために修正しました。やったことは泥臭く、書籍で実装されているget_dot_graph関数を使えばDOT言語仕様の文字列作成→DOTファイルが作成されるので、それを直接いじって各ノードの色を変え、コマンドでpngとして出力しただけです。
これをやるんだったら前段の処理もいらなくて最初っからDOTファイルいじれば良かったのでは
ステップ28 関数の最適化
勾配降下法を実装します。いわゆる普通の勾配降下法の実装(繰り返し回数指定したループを回して勾配に学習率かけて元の値を更新)なので、事前知識があれば難しいことはありません。
ただ、完全に誤解してたのですが勾配と微分って別物なんですね…少し改変していますが、
複数の微分をまとめたもの――ベクトルの形にしたもの――は、勾配(gradient)や勾配ベクトルと呼ばれます。
と説明されています。他の記事で微分のことを勾配と連呼していたので修正しなくては。。
ステップ29 ニュートン法を用いた最適化(手計算)
別途最適化アルゴリズムについて学んでいたとき1、「ニュートン法は収束は速いが計算量が多くてまだ実用されていない」と見たのでなぜ紹介されているのかちょっと混乱。ただ先々読み進めないと分からなそうなので気にせず先へ進みます。
ステップ30 高階微分(準備編)
これまでに実装したforward~backwardでどのようなフローで微分が求められるかの振り返りです。まとめながら読み進めていたのですんなり読めました。
ステップ31 高階微分(理論編)
「高階微分を求めるにはVariableのgradにも"つながり"を持たせる必要がある」という説明のステップです。これまでのことを理解していれば難しくありませんが、「gradはndarrayインスタンスを保持している」というのが頭にないと混乱するかも知れません。
これまでのDeZeroの各クラスが所持しているインスタンス変数と、そこで保持しているデータの型(?)をまとめると以下の様になっています。
クラス | 変数 | 型 |
---|---|---|
Variable | data | ndarray |
name | str | |
grad | ndarray | |
creator | Function | |
generation | int | |
Function(を継承したクラス) | generation | int |
inputs | Variable | |
outputs | Variable |
上の表のVariable.gradの型をndarray→Variableにしようというわけです。Variableにすればgrad自身がcreatorを持つことができ、逆伝播方向にも繋がりを作ることができます。
インスタンス変数に格納されたインスタンスの先頭を大文字で表すと、今まではInputs.creator-Creator.Inputs-...で繋がっていたのを、Grad.creator-Creator.grad-...でも繋げるイメージです。
ステップ32 高階微分(実装編)
メインでやっていることはgradをVariableにするだけです。ステップ20辺りでVariableをそのまま演算できる様にしているので、難しいことはありません。a→np.array(a)としている様なもんです。
Variableに逆伝播の無効モードを追加するところだけ少しややこしかったです。やっていることはステップ18でFunctionに無効モードを追加しているのとほとんど変わりませんが、なぜ今回はVariableに追加?となりました。
と頭に叩き込んでからフローを考えると整理しやすかったです。
ステップ33 ニュートン法を使った最適化(自動計算)
実装したものを使っているだけなので難しいことはありません。
ステップ34 sin関数の高階微分
ここも今までやったこと(Functionを継承した関数のクラスを作ったり、高階微分をしたり)をsin関数でやっているだけなので、すんなり読めます。
ステップ35 高階微分の計算グラフ
書籍の主題からそれますが、活性化関数にも使われるハイパボリックタンジェント(tanh関数)が出てきました。全然使ったことがなく頭に入っていないので、数学的性質をメモることで叩き込みます。
tanh関数は以下の式で表されます。
の時に
が大きくなるほど分母も分子もに近づくのでに近づきます。
が小さくなるほど分母は、分子はに近づくのでに近づきます。
グラフを描画してみます。
def tanh(x): return (np.exp(x)-np.exp(-x))/(np.exp(x)+np.exp(-x)) x = np.linspace(-5,5,1000) y = tanh(x) plt.plot(x,y) plt.show()
シグモイド関数が0を突き破って下方向に伸びた形をしています。
微分は書籍で紹介されている変形以外にもで表す方法とで表す方法があるようです。以下は書籍の例で、分数関数の微分の公式を用います。
、なので、以下の様に変形できます。
ステップ36 高階微分以外の用途
高階微分(ができる機能)を使うと実装できる論文が紹介されているのですが、とりあえず深入りせずに先へ進みます。
最後に
次のステップからいよいよニューラルネットワークを作る機能の実装で楽しみです!~ステップ41までをまとめる予定。
ゼロから作るDeep Learning3 フレームワーク編を読む その③ステップ15~24
はじめに
以下の記事の続編です。 cha-kabu.hatenablog.com cha-kabu.hatenablog.com
本編
ステップ15 複雑な計算グラフ(理論編)
これまで扱ってきたのは一直線の計算グラフですが、各層のノード数が複数になると逆伝播がうまくいかないことを説明するステップです。内容は難しくなく、コードも出てこないので理解しやすいです。
ステップ16 複雑な計算グラフ(実装編)
ステップ15で解説された問題を解決するために、①VariableクラスとFunctionクラスにそのインスタンスが生成されたときに"世代"を記憶できるように新しいインスタンス変数generationを追加します。②generationの順に逆伝播するようにVariableクラスのbackwardメソッドを変更します。
図示するほどのややこしさは無いですが、前の記事でまとめておいた順伝播~逆伝播のフローが変わるので、理解を深めるためにまとめておきます。
①インスタンス変数generarion追加方法
まずはVariableクラスの場合。
class Variable: def __init__(self, data): ### 省略 ### self.creator = None self.generation = 0 def set_creator(self,func): self.creator = func self.generation = func.generation + 1 ### 省略 ###
0で初期化します。ですので手動でVariableインスタンスを生成した時は何か意図的に変更しない限りはgeneration=0となります。
一方Variableインスタンスは手動で生成するだけでなくFunctionインスタンスからも生成されます。そしてその場合、自動的にset_creatorメソッドが呼び出されるフローになっています。
set_creatorメソッドでは、前のステップまでの通り"生みの親"を記憶しておくcreatorに親であるFunctionインスタンスを格納しつつ、新しい機能としてgenerationを0から「生みの親の世代+1」に更新します。
続いてFunctionクラスの場合。
class Function(object): def __call__(self, *inputs): ### 省略 ### self.generation = max([x.generation for x in inputs]) ### 省略 ###
Functionクラスは引数inputsとしてVariableインスタンスを受け取ります。つまり入力は必ずインスタンス変数generationを持っており、複数の入力のgenerationを全部調べてその中で最大のものを自身のgenerationとします。
②generationの順に逆伝播させる
これまでの逆伝播はVariableクラスのbackward()メソッド内で大雑把に以下のフローで実施されていました。
- 生みの親のFunctionインスタンスをリストfuncsに追加する
- funcsの後ろに格納されたFunctionインスタンスから勾配の更新を行う
- 勾配を更新したFuncitonインスタンスの引数となったVariableインスタンスに注目する
- 1-3をループする
こちらのフローの1~2の間に、「リストFuncsを世代昇順にソートする」というフローが加わるだけです。オリジナルの簡単な例で見てみます。
# インスタンス変数generationを持つだけのクラス class Temp: def __init__(self, g): self.generation = g # インスタンス化(うち二つは同じ引数で中身は一緒にしておく) a = Temp(1) b = Temp(2) c = Temp(1) # 空のlistとsetを作成 funcs = [] seen_set = set() # 引数がseen_setに無ければfuncsとseen_setに追加しfuncsを各generation順でsortする関数 def add_func(f): if f not in seen_set: funcs.append(f) seen_set.add(f) funcs.sort(key=lambda x: x.generation) # add_funcの実行 add_func(b) add_func(a) add_func(a) add_func(c) # funcsに格納された各インスタンスのgenerationを確認 print([i.generation for i in funcs]) # [1, 1, 2]
Tempクラスはオリジナルに作ったものですがadd_func関数は書籍と同じものです。
add_funcの実行はb→a→a→cの順で行っており、前ステップまでの状態ではfuncsリストに格納されるのはそのまま[b, a, a, c]でした。各インスタンスの世代で書き換えると[2, 1, 1, 1]です。
しかし今回実際にfuncsに格納された各インスタンスのgenerationを確認した結果は[1, 1, 2]となっており、変数名で書き換えると[a, c, b]の順になっています。ポイントは以下です。
- aが一つになっているが、cとは区別されている
if f not in seen_set~で同じインスタンスはlistに追加しない - 世代の昇順になっている
funcs.sort~を、keyをgenerationにして実行
ステップ17~ステップ24
ここから「書いてあることは簡単だけど、本質的なことは恐らく全然分かってない」と思える内容が続きます。Deep Learningについてというよりかは、Pythonとかプログラミングそのものについての話なので、「本書を通してDeep Learningについて学びたい」が主目的の場合はあまり深入りしなくても良い部分かと思います。
自分も逃げる深くは立ち入らないでおこうと思います…が、デコレータと特殊メソッドについてはコンペのkernelとかでも良く見る処理で、良くわからずに真似してしまっているのでそのうちまとめて別記事にしたいなと思います。
最後に
逃げてしまったので前2記事と比べるとずいぶんとあっさりした記事になりました。次回は未定…というか知らない言葉が多くて目次見るだけだとどこでキリが良いのか分からない。。ステップ25~もDeep Learningそのものというよりかは数学力を試されるステップになりそうですが、ここについては逃げずに(ある程度は)ちゃんと理解しながら進めたいと思います。
ゼロから作るDeep Learning3 フレームワーク編を読む その②ステップ8~14
はじめに
こちらの記事の続きのシリーズです。
本編
ステップ8 再帰からループへ
ステップ7でbackwardを自動実行する際、再帰的な実装を行いましたがそれをループに修正します。つまずきは無いものの現段階では実装の必要性がわかりません。しかしステップ15で役立つとのこと。
ステップ9 関数をより便利に
以下の4点を改善します。
- 今のままでは関数を使うのにいちいちインスタンス化→呼び出しと手間がかかってしまうので、インスタンス化&呼び出しを一括で行ってくれる関数を定義
- の勾配(絶対1)をいちいちy.gradに格納する必要がある手間を省くための実装
- Variableの引数dataにndarray以外が渡されたときに例外を発生させる
isinstance()
とraise TypeError()
の活用
- 0次元のndarrayを計算した場合、結果がnumpy.float64などのndarray以外のスカラ系の型になってしまう問題の対処(対処しないと3の機能によりエラーが起きる)
isscalar()
の活用
こちらもつまずきはありませんでしたが知らない実装が色々あったので忘れないようにしなければ…
ステップ10 テストを行う
知らない内容だらけですが難しくはなかったです。しかしこの機能を使いこなせる気がしない…Deep Learningについて学ぶだけが目的であればこのステップへの深入りは後回しでも良いかなと思います。
また、自分の環境(Jupyter Notebook)だと書籍の通りに実行するとテストモードを実行するコマンドunittest.main()
でエラーになります。何がどうなってエラーが起きて解決できているのかはよく分かっていませんが、ネット上の知識人の情報からコマンドを以下に修正すればとりあえず動きます。
if __name__ == '__main__': unittest.main(argv=['first-arg-is-ignored'], exit=False)
ステップ11 可変長の引数(順伝播編)
入出力が複数になる場合の順伝播に対応します。これまでの関数は入出力ともに一つでそれだけを考慮した形でしたが、足し算や掛け算などの入力が複数になるケースや多次元配列を複数に分割する関数などの出力が複数になるパターンに対応させます。ここもまだ大丈夫そうです。
ステップ12 可変長の引数(改善編)
主に以下の2点を改善します。まだ大丈夫。
- Functionクラス(を継承した関数のクラス)から作ったインスタンスに与える引数が、現状だとリスト型にしないといけなくて面倒なので個別に渡せる様に修正
add([x0, x1])としなくてはいけなかったところをadd(x0, x1)に対応させる感じ - 1と同じようなことを関数のクラス自体を新しく作るときにもできるようにFunctionクラスを修正
ステップ13 可変長の引数(逆伝播)
説明されていることは分かるのですが、頭の中の関係図が追い付かなくなってきました…ダブる部分が多いので、次のステップ14とあわせてクラスどうしの関係を図式したいと思います。ここではつまずいた訳ではありませんが忘れがちな足し算と掛け算の逆伝播についてまとめておき知識を定着させたいと思います。
足し算の順伝播
上流から流れてきた勾配をそのまま二つの下流に流します。
左側が順伝播、右側が逆伝播の図で、順伝播側は当然ですがとを足したものなので、出力はとなります。
逆伝播側はまず基本のおさらいとして、あるノードから下流に流れる勾配は「上流から流れてきた勾配×自身の入力についての勾配」となるのでした。
図では、上流から流れてくる勾配はひとつだけで(から先のグラフは省略しているので具体的な値はこの図からは分かりません)、これがが入ってきた方向とが入ってきた方向両方に作用します。
そしてそれぞれの方向に関する勾配はというと、をについて微分したものとについて微分したもののなので、
とどちらも1になります。元の数に1を掛けても変わらないので、結局下流のが入ってきた方向にもが入ってきた方向にも流れる勾配は同じで、となります。
掛け算の順伝播
上流から流れてきた勾配に、他方の入力を掛けて下流に流します。順伝播の時の関係をひっくり返した関係になるので少しややこしいです。
こちらも左側が順伝播、右側が逆伝播の図で、順伝播側はとを掛け合わせたものなので、出力はとなります。
逆伝播側で上流から流れてきた勾配は足し算のときと同じですが、それぞれの方向に関する勾配はというと、をについて微分したものとについて微分したもののなので、
とどちらも相手側の入力値になります。ということで図の通り、下流のが入ってきた方向には上流からの勾配にを掛けたもの、が入ってきた方向には上流からの勾配にを掛けたものが流れることになります。
ステップ14 同じ変数を繰り返し使う
ここのステップの主題は同じ変数をつかって複数回関数に通すと勾配が誤った値になってしまうことへの対応と、その対応をすることにより新たに生じる同じ変数を使って複数回勾配を求めると誤った値になってしまうことへの対応です。ステップ13で保留していたものと合わせて、ステップ14時点での各クラスの関係を前回からの変更点に注目して図にまとめます。
※相変わらず表記の統一が取れていません…雰囲気で掴んでください。ステップ毎に表したい内容が変わるので探り探り「今回はこう書いたらまとまり良いかな」と都度考えを変えながらやっており、分かりづらくてすみません。
順伝播
※書籍ではAddクラスのインスタンス化&引数渡しを一度で行うadd関数が実装されていますが、簡易化のため省略しています。
上図の通り、aとbをAddという二つを足し合わせる関数のクラスに通し、出力するまでの流れで確認します。
また、説明の前提としてaとbはVariableクラスを手動でインスタンス化したものとしています。手動で作成したものなので生みの親を保持するcreatorはどちらもNoneになっています。dataはどちらも1次元で要素を2つもつndarrayインスタンスです(スペースの関係上リストとの区別がでできなかったので、ndarrayはオレンジ、リストは黒で表現しています)。
AddはAddクラスをインスタンス化したものです。Addでは以下の順で処理が進みます。
- 複数の引数aとbを受け取とりそのdataを変数xsにリストで格納。
- xsを自身のforwardメソッド(2つの引数を足す関数)に通し、出力結果を変数ysにタプルで格納。
- ysの各要素をVariableの引数としインスタンス化、それをリストにまとめたものをoutputsとする。
- これが次のノードに渡される出力になります。
- ysの値は作成したVariableインスタンスのdataに格納されます。
- outputsの各要素のインスタンス変数creatorにAddを格納する。
- outputsを自身のインスタンス変数outputsに格納する(後で使う)。
- 引数a,bを自身のインスタンス変数inputsに格納する(後で使う)。
- outputsを返す。
- outputsの要素が複数になる場合、リスト化されて格納されます。
- 今回はoutputsが1つしかないので、リストではないことを表す意味でoutputsを破線の[]で囲っています。以下ややこしいのでご注意ください。
- リストの状態になっているのはAddに保存されているoutputsで、次の各ノードにはそれぞれoutputs[0],outputs[1],...が渡されます。リスト型のオブジェクトがそのまま次のノードに渡されるわけではありません。
- 今回outputsは1つですがoutput.dataに格納されたys(ndarray)の要素は2つです。「出力要素が複数」と「出力要素の要素が複数」は別の意味だと把握するのに混乱しました…
逆伝播
めちゃくちゃややこしい図になりました…図の説明の前に前提が2つあります。
- 順伝播の時の設定と異なり、後々の説明を考えてa, bより下流にも層があることとします。ただし、一つ下にいる関数が何なのかは図では不明なので、creatorは?としておきます。
- この図はこれより先にもノードが存在し、最終地点の出力の名をLとします。
2について補足すると、逆伝播ではLからどんどんと勾配が更新されながら下ってくるわけですが、図より先のノードがどうなっていようとも、上流から流れてくる勾配=Addに格納される勾配はLをoutputsで微分した値であることには変わりません。
outputsを簡略的にcで表わすとoutputsの持つ勾配はで、この値が上流から流れてきてoutputsのインスタンス変数gradに格納された状態(黒線のフローまで完了したところ)までを前提としてそのあとの流れを説明します。
長くなるので区切って説明します。まずは赤線のフローから。
上流からの流れを受けて、c.backward()が自動実行されます。
cは自身のcreatorに格納された親を確認し変数funcsに格納、そのうちの一つを変数fに格納します。つまり、fはここではAddの一時的なコピーです。
- funcsはリスト型で、以降順次値が追加されていきます。話が飛んでしまいますが後程aとbのbackward()が(自動で)呼び出されたときには、aとbの親を探してきて新たに追加します。
- ですので、実はここでの説明は少し嘘があります。cが変数funcsを作ったかの様な書き方をしていますが、実際は最初にLが作ったfuncsに下流の各ノードが生みの親を追加していきます。
grad、すなわち上流から流れてきてcに格納されていた勾配を、新たな変数gysに渡します。
- gysはリスト型でgradが保管されます。outputsが複数ある場合、上流から流れてくる勾配が複数になるのでgradも複数になります。
f(今回は中身はAdd)のbackward()メソッドをgysを引数に実行します。ステップ13のところでまとめた通り、足し算の逆伝播は入力をそのまま下流に流すので、2つのgyを返します(そうなる様に、Addクラスのbackward()メソッドを実装しています)。
- gysから急にgyに名前が変わったのは、Add.backward()の入力はひとつ(足し算の出力はひとつなので)で引数のデフォルト名称がgyと名付けられているためです。呼び方だけの話で値そのものは変わっていません。
二つの返り値gyを、タプルとして新しい変数gxsに格納します。
以上が赤線までの流れです。青線のフローに入る前に図で表現できていない処理の説明です。
今回はf.backward()の返り値が2つの前提なのでgxsに自然とタプルで格納されます。しかし要素が一つの時―すなわちf.backward()の返り値が一つの場合は値そのものが格納されてしまうので、if文を使って要素が一つの場合はタプルに変更します。これは後続の青線のフローの処理をする際にイテレータである必要があるためです。
ちなみに返り値2つだとタプルになるの、いつも複数出力は複数変数で受け取っていてピンとこなかったので一応試してみました。
a = 1,1 print(a) # (1,1)
続いて青線のフローです。
- zip(f.inputs, gxs)でfor文を回します。1ループ目はaとgy(aに流すべき勾配)のセット、2ループ目はbとgy(bに流すべき勾配)が呼び出されます。
- for文でgyを受け取る変数の名称が書籍上gxになっているので、以降のフローではgyをgxと呼び変えます。値はgyから変わりませんのでご注意ください。
- aとbのインスタンス変数gradにgxを格納します。このとき、aもしくはbのgradがNoneであればgxをそのまま格納、そうでなければ―すなわち、すでに他のノードから勾配を受け取っていた場合は、既存のgradにgxを足してgradを更新します。
- aとbのインスタンス変数creatorを確認し、funcsに格納します(Noneなら何も格納されない)。
- ループの頭(赤線フローの1)に戻り、a.backward()→b.backward()が実行されます。
以上!!
最後に
ひとつひとつの処理は簡単なのですがどんどん頭の中の設計図が追い付かなくなってきて地味にここまでまとめるのにめちゃくちゃ時間使いました。次は~ステップ19か24までのまとめを予定。
ゼロから作るDeep Learning3 フレームワーク編を読む その①ステップ1~7
はじめに
ゼロから作るDeep Learningの①と②を読み終えて(=写経した≠理解した)、Pytorchやtensorflowといったライブラリを使って開催中のいくつかのコンペにチャレンジしています。
一応各種ライブラリは使うことはできてコンペにsubmissionファイルを提出する程度のことはできるようになりましたが、いまいち理解しきれていない感じがしたのと、特にPytorchで多用するclassの扱いに全然慣れていないことから、ゼロから作るDeep Learning③フレームワーク編を読み始めました。
ちゃんと理解しながら進めたいと思い、こちらでつまずいたところを中心にアウトプットしていきたいと思います。機械学習もですがプログラミングも初心者だと今作はしんどい…
ゼロから作るDeep Learning③フレームワーク編について
オリジナルのDeep Learningフレームワーク"DeZero"をゼロから作ることを通して、Deep LearningそのものだけでなくPytorch、tensorflowといったライブラリの裏側まで理解しようという本です。
まだ読み始めたところですが各種レビューで言われている通り名著だと思います!できる限り簡易な言葉で書かれているので初心者でも理解しやすいですし、本当にゼロから作っていくので読破した時に理解が深まってること請け合です。
ただ、初心者でも理解しやすいといってもある程度の知識は必要で、Pythonの基本文法(しょっぱなからclassの継承など多様されます)や微分・線形代数の基礎知識は必須です。
また、Deep Learningの基礎知識(学習がどうやっての進むかくらい)もなくとも読めなくはないと思いますが、全体像の説明は無しにステップバイステップで進んでいくので「今これなんのためにやってるの?」となりそうなので、モチベーション維持の意味でもあった方が無難かと思います。
本題
コードを載せると長くなりすぎるので適宜はしょります。気になる方は書籍のGitHubにステップ別にまとめられているのでそちらご確認ください。
ステップ1 箱としての変数
しょっぱなからつまずきました。「変数を実装しましょう」といって変数のクラスを作るのですがなぜそんなことをするのか分かりません。変数なんてクラスにしなくてもa=0とかで指定してやれば良いだけでは…と思っていたのですが、読み進めるにつれてなんとなく理由が分かってきました。
このクラスから作られる変数には後々各ノードでの計算結果などが格納されることになります。そして誤差逆伝播などを考えると「自分自身の値」だけでなく「自分の位置と対応する勾配」などの情報も持つ必要がでてきます。その際、シンプルな変数宣言だと(できないことは無いと思いますがコードが複雑になるので)情報を持ちきれなくなってしまうため、クラスにしつつ各値をプロパティとして持つ必要があります。
ここのステップだけ読んでいても必要性は分かりづらいのと、やっていること自体は簡単なので理由は諦めて先に進むことをお勧めします。
ステップ2 変数を生み出す関数
ここはクラスの継承や__call__メソッドが普通に出てくるのでクラスに慣れていないと厳しいですが、やってることはシンプルです。様々な関数を扱うクラスを作成します。
ステップ3 関数の連結
ここもつまずきは無し。ステップ2で作成した関数を扱うクラスを継承した関数(のクラス)を作成し、実際に計算を試してみるだけです。
ステップ4 数値微分
数学の授業では習わなかった数値微分の中心差分近似が出てきますが微分を理解していれば特につまずかないと思います。(実際は微分の理解が甘くの微分でつまずいたのですが後程)
ステップ5 バックプロパゲーションの理論
話は難しいですが誤差逆伝播法の知識があったり前々作を読んでいればつまずかないと思います。もしつまずくようでも次のステップで具体例を確認しながら理解した方が良いと思います。
ステップ6 手作業によるバックプロパゲーション
つまずき①微分
自分の数学力が低すぎただけですが、試しにやってみた手計算の結果とプログラムの結果が合わず、ちゃんと手を動かさないとダメだなと思いました…誤差逆伝播の復讐を兼ねて詳しめにまとめます。
以下のような計算グラフの順伝播/逆伝播を計算します。は入力もしくは出力、は入力を二乗する関数、はの入力乗を出力する関数で、です。
順伝播、微分、逆伝播三種類の計算を行います。
1. の値がどうなるか(順伝播)
普通に計算するだけで、以下の様になります。
2. を求める(数式微分)
をについて微分していきます。この際、「は微分しても」でこれも変わらないと思っていたのですが、の肩が関数の場合は以下の様になるのを初めて知りました。数学力の低さが課題。
というわけで、この公式を利用すると以下の様になります。
仮にとすると、
3. を求める(誤差逆伝播で解く)
以下のチェインルールと、を思い出しつつ、
から、で先ほどの結果と一致します。
つまずき②クラスの関係がややこしくなってきた
一応理解はできたのですが、この辺りから各クラスのインスタンス変数やメソッドが多くなってきて「何でその情報をこのクラスに持たせるの?」という混乱との戦いが始まりました。
オブジェクトの関係性を整理しないと訳が分からなくなるので、以降のことも考え図でまとめながら読み進めることにします。まずは各クラスを個別にみてみます。
Variableクラス
変数のためのクラスで、初期値や各ノードで計算された計算結果および勾配といった様々な値は(今のところ)すべてこのクラスのインスタンスとします。ステップ1でも触れましたが自分自身の値dataに加えて、今後勾配gradを保存していくためのインスタンス変数を持ちます。
class Variable: def __init__(self, data): self.data = data self.grad = None
今のところのイメージは↓のような感じです。表の様に見えるものは正面から見た箱か棚だと思って書いています。
Variableという名前の箱に、現状はdataとgradというステータスの保管場所だけがあります。この箱は複製可能で複製先で名前を変えることも可能で引数にdata(ndarray型)を渡すと複製したやつのdataにそれが保管されます。gradは最初は空っぽですが後から中身が入れられます。
Functionクラスとそのサブクラス
様々な関数の親となるFunctionクラスがありますがこれは計算は行わず、計算の種類ごとにサブクラスを作ります。おそらくですが今後ノードや活性化関数の計算に使われると思われます。
Functionクラスはただの入れ物です。__call__メソッドにサブクラス共通の処理だけ書かれています。ざっくり言えば「入力を受け取って計算して出力する」の流れだけを持っています。
他のメソッドにforwardとbackwardを持ちますが、これは間違ってサブクラスではなくFunctionクラスをインスタンス化してしまったときに例外処理をするためで、計算上の意味はありません。
サブクラスは現状↑ででてきた2つの関数(入力の二乗を返すものとの入力乗を返すもの)だけが定義されています。
class Function: def __call__(self, input): x = input.data y = self.forward(x) output = Variable(y) self.input = input return output def forward(self, x): raise NotImplementedError() def backward(self, gy): raise NotImplementedError() # サブクラスその1 class Square(Function): def forward(self, x): y = x ** 2 return y def backward(self, gy): x = self.input.data gx = 2 * x * gy return gx # サブクラスその2 class Exp(Function): def forward(self, x): y = np.exp(x) return y def backward(self, gy): x = self.input.data gx = np.exp(x) * gy return gx
イメージは↓の感じでしょうか。
Functionはいろいろな情報が入ったinputという名前の箱を受け取ります(そして、それを自分の中にも保存しておきます)。箱の設計図は先ほどのVariableの通りです。その中の値(data)だけを取り出してxとし、まだ中身の決まっていないforwardというxに何かしらの計算をする関数に投げて、返ってきた値yをまたVariableの設計図通りにできた箱に入れて、その箱をoutputと名付け誰かに渡します。この一連の流れはすべて__call__メソッドで行われます。
サブクラスは__call__メソッドはFunctionと同じで「inputを受け取り~outputを誰かに渡す」という働きをします。ただ、サブクラス毎に異なる働きをする2つのメソッドを持ちます(フローは同じ)。
- forward
Functionで中身の決まっていなかったやつです(入ってきた値を二乗して返す、とか)。これでちゃんとxを別の値yに変えてoutputに繋げることができます。 - backward
外からやってくるgyを引数にとり、__call__メソッドで保存しておいたinputの中の値(data)をxとしたものの2つを用いて新たな勾配gxを計算し、次の誰かに渡します。
自分的には整理できたのですが他の人にも伝わりますかね…こういった図を書くうまいフレームワーク、きっとあるんですよね。。IT業界を目指さなかった過去の自分を恨む。
ステップ7 バックプロパゲーションの自動化
ここも理解はできましたが書籍の具体例を用いつつクラス関係図を更新します。いきなり表現方法が変わりすみません。
黒線が順伝播、赤線と青線が逆伝播です。
順伝播
(略された→→→の出力である)をにinputとして渡す
の__call__を呼ぶとそれと対になっているforwardも呼び出され、が出力される
また、同時に__call__内ではのset_creatorが呼び出され、creatorにが格納される(が自分の生みの親をと記憶する。)
逆伝播
- のgradに
np.array(1.0)
を格納(をで微分したら1。意味がなさそうだが後続の処理をするために必要。) - のbackwardを実行(赤線のフロー)
- のcreatorを確認し、生みの親がだったことを取得する
- 生みの親の__call__に保存しているinputを確認し、がinputだったことを取得する
- 生みの親のbackwardを呼び出す。引数には先ほど格納しておいたのgradを渡す
- __call__からinputされたの値(data)を持ってきてxとする
- 勾配を計算(引数になったのgrad 自身のxにおける勾配)
- 自分のinputだったのgradを、↑の値で更新する
- 2-2で取得していたのbackwardが自動実行(青線のフロー、2に戻り、以降2-1で「生みの親」が見つからなくなるまで繰り返す)
最後に
今日のところはここまでです。図が人に伝わる形になっているのすごく不安ですが、自分の中では理解を深めながら進められています。1か月くらいで読破+記事化を済ませたい…次は~ステップ14まで進める予定。
特異値分解について勉強する(ざっくり理解する編)
はじめに
Probspaceのスパムメール判別コンペで初の自然言語処理にチャレンジ中で、これを機に「ゼロから作るDeep Learning②自然言語処理編」(以降、ゼロ作)も読み進めているのですが、2章の特異値分解のところで早速躓いたのでまとめることにしました。ただ、まとめたのが「だいたいどういうものか」までですので、分解に至る数式などは別途まとめたいと思います。
他の記事もそうですが素人が調べながら書いており、誤った情報が含まれる可能性があるので情報の信じすぎにはご注意ください。
概要
特異値分解とは次元削減手法のひとつで、任意の行列を3つの行列の積に分解し、重要度の低い余分な列ベクトルを削ることで元の行列に近似した低次元の行列を作成します。
自然言語処理では文章を単語に分割しベクトル化した上で様々な処理を行いますが、そのまま扱おうとすると語彙数に応じてベクトルの次元数が増えてしまい、計算が大変になってしまいます。
また、複数の文書をベクトル化した行列は「(様々な文書で共通に含まれる単語というのは多く無いので)多くの要素が0=ベクトルのほとんどの要素が重要ではない」状態になりやすく、そのようなベクトルはノイズに弱く頑健性に乏しいという欠点もあるため、次元削減が重要になってきます。
前提知識
特異値分解に入る前に、「固有値と固有ベクトル」「固有値分解」について把握していると理解しやすいです。
これらについては以下二つのYouTube動画が分かりやすかったです。
特異値分解PART1:固有値分解 by 某处生活_LiveSomewhere
【大学数学】線形代数入門⑫(固有値・固有ベクトル)【線形代数】 by 予備校のノリで学ぶ「大学の数学・物理」
詳しい説明はこれらの動画を見て頂ければと思いますが、ポイントとして押さえておきたいのは以下です。
- 行列に対して、を満たすをの固有ベクトル、をの固有値という。
- 固有ベクトルと固有値の組み合わせは複数存在し、行列でまとめて表すことができる。
- →
- 計算を考えてみたら当たり前ですが、の中身であるは、の対角成分になります。
- 計算を考えてみたら当たり前ですが、の中身であるは、の対角成分になります。
- →
- 上式の両辺に を掛けることで、固有値分解できる。
-
- ある行列を別の3つの行列(2つ+1つの逆行列)に変換できた!
-
これが分かっていると特異値分解も理解しやすいです。
特異値分解とは
こちらでも先ほど挙げたYouTubeチャンネルの続編が分かりやすかったです。
特異値分解PART2:特異値分解 by 某处生活_LiveSomewhere
固有値分解の式を見ると、行列は正方行列であることが条件だと気づきます。なぜなら、右辺の両端がととなっており、どちらも同じサイズの行列だからです。
このとき、自然な考えとして出てくる「が正方行列でないときも似たようなことをしたい!」という要望に応えるのが特異値分解で、固有値分解とよく似た数式ですが少しだけ異なる以下の式で分解されます。
※はYouTube動画に合わせた記法で、ゼロ作ではで記載されています。後程のpython実装ではSで表します。
これで分解できる理由は動画を確認頂ければと思いますが、ポイントは以下です。
- との中身は固有値分解のときと同じでベクトルが詰まっています。
- これらの中身のベクトルはそれぞれの特異ベクトルと呼ばれ、区別をつけるためにの各ベクトルは左特異ベクトル、の各ベクトルは右特異ベクトルとも呼ばれます。
- との中身の各ベクトルは単位ベクトルで、長さはすべて1です。
- との中身の各ベクトルは単位ベクトルで、長さはすべて1です。
- これらの中身のベクトルはそれぞれの特異ベクトルと呼ばれ、区別をつけるためにの各ベクトルは左特異ベクトル、の各ベクトルは右特異ベクトルとも呼ばれます。
- の中身も固有値分解の時と同じでスカラーが対角に並んでおり、対角成分以外はすべて0の行列です。各スカラーをの特異値と呼びます。
- はの順に並んでいる必要があります。
は以下の様に展開ができ、こちらの方が特異値分解が次元削減に繋がる理由が分かりやすいです。
とは単位ベクトルであり、は降順に並んでいるという前提から、左の方(添え字が小さい方)ほど値が大きい=の大きな部分を占める=重要で、右の方ほど反対に重要度が低いことが分かります。
この性質を利用して、「重要度の低い右の方はある程度消してしまっても、おおよそのことは説明できるから消しちゃっても良いよね」としてしまうことで、次元削減を実現するわけです。
Pythonでの実装
numpyのlinalgモジュールにあるsvdメソッドで実装できます。せっかくなので正方形でない行列に適用したいので、ゼロ作で適用する共起行列(7×7)から1行減らしたものに適用してみます。こちらのQiita記事を参考にさせて頂きました。
import numpy as np from numpy.linalg import svd # 7*6の行列を作成 C = np.array([[0, 1, 0, 0, 0, 0, 0], [1, 0, 1, 0, 1, 1, 0], [0, 1, 0, 1, 0, 0, 0], [0, 0, 1, 0, 1, 0, 0], [0, 1, 0, 1, 0, 0, 0], [0, 1, 0, 0, 0, 0, 1]]) # 特異値分解(UとSとV^Tに分解) u,s,v = svd(C, full_matrices=False) # u,s,vのshapeを見てみる # C(6×7)を分解した数のはず print("u:",u.shape," s:",s.shape," v:",v.shape) # sの中身確認 # 降順に並んでいるはず print(s.round(2)) # uとsとvを掛けたらCに戻るか確認 # sはnp.diagで対角行列に変換する必要あり # @は行列積を求める記号 print((u @ np.diag(s) @ v).astype("int"))
実行結果が以下。
u: (6, 6) s: (6,) v: (6, 7) [2.32 2.29 1.15 0.87 0.53 0. ] [[0 1 0 0 0 0 0] [1 0 1 0 1 1 0] [0 1 0 1 0 0 0] [0 0 0 0 0 0 0] [0 1 0 1 0 0 0] [0 1 0 0 0 0 1]]
期待通りの出力になっています。
最後に
以上、特異値分解についてまとめました。自然言語処理を入口に学びましたが、当然ながら画像処理等にも使える手法とのことです。またこの記事ではゼロ作の2.4.2~2.4.3節の内容にのみ触れましたが、以降の節ではnumpyのsvdよりも高速なsklearnのrandomized_svdなども使用されています。まだまだ発展がありそうですが、基本は抑えられた気がします。
RankGaussについて勉強する
はじめに
現在参加中の某コンペで、「RankGaussが効く!」とされているのですが、慣れない手法なのでちゃんと調べてみました。kaggle本でもちゃんと紹介されているのですが、普段ニューラルネットから逃げているので全然頭に入っていませんでした…
素人が調べて書いているので、間違いもあるかと思いますので情報の信じすぎにはご注意下さい。
概要
kaggle本の説明が分かりやすかったので引用します。
数値変数を順位に変換したあと、順序を保ったまま半ば無理矢理正規分布となる様に変換する手法です。
数値の大小関係は変わらないのでGBDTなど木関係のアルゴリズムでは効果が無いようですが、特にニューラルネットでモデルを作成する際の変換として通常の標準化よりも良い性能を示すとのこと。
何で良い性能になるのかも調べてみたのですが理解できる情報に巡り合えず…むしろ、「RankGaussからMinMaxScalerに変えたらうまくいった」*1なんて記事もあったので、色々やってみる選択肢の一つとして捉えた方が良いのかも知れません。
低い理解力でメリットとデメリットを考えると、以下のあたりでしょうか。
メリット:値→順序に直すので、極端に頻度の高い値をある程度分散させられる?外れ値の影響が軽減される?
デメリット:順序尺度になってしまうので、差や比を捉えられなくなる?
実装
titanicデータセットを使って実装してみたいと思います。まずはデータの用意と簡単な前処理から。
import pandas as pd import matplotlib.pyplot as plt import seaborn as sns sns.set() # データのロード df = sns.load_dataset("titanic") # 欠損データの削除 df = df.dropna(subset=["age"]).reset_index(drop=True) # ヒストグラムの確認 df["age"].hist() plt.show()
ほぼほぼ正規分布に近いですが、若干崩れています。
ライブラリを使った実装
scikit-learnのQuantileTransformerで簡単に実装できます。DataFrameに適用する場合は不要ですが、Seriesに適用する場合はreshapeしてやる必要があります。
from sklearn.preprocessing import QuantileTransformer # RankGaussによる変換を定義 transformer = QuantileTransformer(n_quantiles=100, random_state=0, output_distribution='normal') transformer.fit(df["age"].values.reshape(-1,1)) # 変換後のデータを新規列に格納 df["age_quantiled"] = transformer.transform(df["age"].values.reshape(-1,1)) # ヒストグラムの確認 df["age_quantiled"].hist() plt.show()
正規分布っぽくなりました。
自力で実装してみる
ライブラリを使うと簡単ですが、何が起こっているのか良くわからないので、できるだけ自力で実装してみます。
なお、先に言っておくとそれっぽい結果にはなるので全然違うことをやっているわけではなさそうですが、全く同じ結果にはならなかった&他の自力実装している人のGitHubを覗くともっと難しいことをやっていたので、あくまで雰囲気を掴むためだけにチャレンジしています。実際のアルゴリズムとは異なるのでご注意ください。
まずはageをrankに変換して、各列を昇順にして結果を確認します。
# ageをrank変換 df["age_rank"] = df["age"].rank() # 変換結果を確認 df[["age","age_rank"]].sort_values("age")
age | age_rank |
---|---|
0.42 | 1.0 |
0.67 | 2.0 |
0.75 | 3.5 |
0.75 | 3.5 |
0.83 | 5.5 |
... | ... |
70.50 | 710.0 |
71.00 | 711.5 |
71.00 | 711.5 |
74.00 | 713.0 |
80.00 | 714.0 |
714 rows × 2 columns
ちゃんと順番通りにrankがついています(同じ値があると平均が取られxx.5などの順位になります)。
続いて、作成したage_rankを-1~1の範囲にスケーリングします。この際、後程処理時に-1と1に変換された値はinfになってしまうので、すごく小さな値を足して(引いて)おきます。
from sklearn.preprocessing import MinMaxScaler # MinMaxScalerを定義 # 範囲は-1+極小値~1-極小値 scaler = MinMaxScaler(feature_range=(-1+(1e-7),1-(1e-7))) scaler.fit(df["age_rank"].values.reshape(-1,1)) # 変換後のデータを新規列に格納 df["age_rank_scaled"] = scaler.transform(df["age_rank"].values.reshape(-1,1)) # 変換結果を確認 df[["age","age_rank","age_rank_scaled"]].sort_values("age")
age | age_rank | age_rank_scaled |
---|---|---|
0.42 | 1.0 | -1.000000 |
0.67 | 2.0 | -0.997195 |
0.75 | 3.5 | -0.992987 |
0.75 | 3.5 | -0.992987 |
0.83 | 5.5 | -0.987377 |
... | ... | ... |
70.50 | 710.0 | 0.988780 |
71.00 | 711.5 | 0.992987 |
71.00 | 711.5 | 0.992987 |
74.00 | 713.0 | 0.997195 |
80.00 | 714.0 | 1.000000 |
714 rows × 3 columns
順位が-1(+極小値)~+1(-極小値)の値に変換されました。
最後に、age_rank_scaledを逆誤差関数(誤差関数の逆関数)
に通して正規分布になる様に変換します。なにやら難しい関数ですが、scipyに関数が用意されていますので、計算は機械に任せてしまいます。
from scipy.special import erfinv # 逆誤差関数の適用 df["age_rank_scaled_norm"] = erfinv(df["age_rank_scaled"]) # ヒストグラムの確認 df["age_rank_scaled_norm"].hist() plt.show()
QuantileTransformerの結果と一致はしていませんが、なにやらそれっぽくなったので大体あっているはず…
さいごに
RankGaussについてまとめました。大小関係は変わらないので木のアルゴリズムには無意味というのは分かりやすいですが、何故ニューラルネットだと他のスケーリングに比べて効きやすいのかまではまだよく分かっていません…その辺は色んなところで使いながらどんなところで効く/効かないを掴んでいきたいと思います。