「ゼロから作る Deep Learning 3 フレームワーク編」の読書まとめ。思い出すためのまとめメモです。解説ではないので書籍を読んでいる前提です。第3ステージ、目的は「ニューラルネットワークを作る」です。
コードはこっち
第4ステージ ニューラルネットワークを作る
このステージはさらに大きく2つに分けられます。前半は必須・便利関数の追加とその説明、後半はフレームワークとして定番・使いやすい形への実装になってます。これまでスカラで扱ってきた計算がテンソルになっています。が要素同士計算するだけで大きく基本は変わらないのでそこはいったんパスしときます。
だいぶ長いステージですが、これでだいぶフレームワークっぽい見た目になってきます。
必須関数
まずは7つの必須・重要関数から
形状を変える関数(reshape/transpose)
まずはテンソルの形状変形。reshapeとtransposeの2つの関数の順伝播と逆伝播。どちらも実装はシンプル
1 2 3 4 5 6 7 8 9 10 11 | class Reshape(Function): def __init__( self , shape): self .shape = shape def forward( self , x): self .x_shape = x.shape # ここで覚えておく y = x.reshape( self .shape) return y def backward( self , gy): return reshape(gy, self .x_shape) # 元に戻す |
元の形を覚えておくだけ。逆伝播ではそのもとの形に直すだけ。transpose(転置)も同じように
1 2 3 4 5 6 7 8 | class Transpose(Function): def forward( self , x): y = np.transpose(x) return y def backward( self , gy): gx = transpose(gy) return gx |
転置するだけ。forwardで覚えておく必要はなし。ただnumpyのtransposeはもっとできることが多いのでfunctions.pyにある関数はもうちょっと複雑です。
和を求める関数・ブロードキャストを行う関数
この2つの関数は表裏です。和を求める関数の逆伝播がブロードキャストで、ブロードキャストの逆伝播が和になります。ブロードキャストではなく分岐ノードと表現したほうがわかりやすいです。分岐(ブロードキャスト)の逆伝播は分岐したものを束ねる(加算)になります。束ねた(加算した)ものの逆伝播が分岐(ブロードキャスト)であればもう少し文章としても理解しやすいです。
sumとsum_toが出てきますが、本質的には同じものだと思ってみとけばよさそうです。sumはnumpy.sumに引数をそろえたのかな。broadcastの逆伝播を表現するにはsum_toのほうがわかりやすい。
行列の積を行う関数・線形変換を行う関数
第1巻でさんざん使った関数です。概念としては理解できます。
1 2 3 4 5 6 7 8 9 10 | class MatMul(Function): def forward( self , x, W): y = x.dot(W) return y def backward( self , gy): x, W = self .inputs # 覚えておいた入力xと重みWを使う gx = matmul(gy, W.T) gW = matmul(x.T, gy) return gx, gW |
掛け算の順番が逆ですが、行列のサイズをもとに戻そうと思うとこの順で正解です。ちなみにbackwardでさらっと転置を意味する.Tを使っていますが、これはVariableクラスに
1 2 3 | @property def T( self ): return dezero.functions.transpose( self ) |
この関数を加えないとできないです。逆伝播関数の引数とメンバ(gy,self.inputs)はあくまでVariableです。
これにバイアスを組み合わせたものが線形変換になります。1巻ではAffineというクラスで作られていたそれです。
普通にmatmulとaddで作ってもよいのですが、以降参照することがない中間値や微分値を保持するメモリがもったいないので、これらの計算をひとまとめにしたlinerという関数を作って対応します。
1 2 3 4 5 6 7 8 | def linear_simple(x, W, b = None ): t = matmul(x, W) if b is None : return t y = t + b t.data = None # Release t.data (ndarray) for memory efficiency return y |
とはいえこの関数はsimpleバージョンで、実際はちゃんとFunctionを継承したクラスも存在しています。
活性化関数
最後が活性化関数です。いったんシンプルなsigmoidとして
1 2 3 4 | def sigmoid_simple(x): x = as_variable(x) y = 1 / (1 + exp(-x)) return y |
これもちゃんとFunctionを継承したSigmoidを作るのが正しいです。
これらの関数を駆使することでニューラルネットワークのモデルがちゃんと書けて、またちゃんと学習もできて、ちゃんと推論もできる。というところまで確認できるのですが、いわゆるDeepLearningのフレームワークのようなスマートさがありません。
次のステップからモダンフレームワークで見かけるスマートなクラスを実装していきます。
ニューラルネットワーク実装に向けたクラス
ここからDeepLearningのフレームワークとして定番のクラスを作っていきます。なんか見たことあります。
Parameterクラス
これは明示的に学習対象のパラメータだよ。ということを見分けるためのクラスで、実態はVariableそのもの。実装も
1 2 | class Parameter(Variable): pass |
これだけ。
1 2 3 | x = Variable(...) # xはVariable p = Parameter(...) # pはParameter y = x * p # この計算は可能。yはVariable |
目的はクラスにあるパラメータを単純な変数Variableと見分けるためです。isinstanceで区別できるようになります。
Layerクラス
少しPythonコードに慣れていないと読み解けないクラスです。
1 2 3 4 5 6 7 8 | class Layer: def __init__( self ): self ._params = set () # パラメータ名の集合 def __setattr__( self , name, value): if isinstance (value, Parameter): # Parameter型が設定されたら self ._params.add(name) # パラメータ名の集合にその名前を追加 super ().__setattr__(name, value) |
この__setattr__()がどんなときに呼ばれるかというと、
1 2 3 | layer = Layer() layer.p1 = Parameter(...) # ここで__setattr__がname=p1、value=Parameter(...)で呼ばれる |
といった感じ。このコードの場合valueがParameter型なので_paramsにp1の名前が登録される。じゃあ今度このvalueにどうやってアクセスするかというと、
1 | layer.__dict__[ "p1" ] |
で取り出すことができます。これを踏まえて、もう4つ関数を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | class Layer: def __init__( self ): self ._params = set () # パラメータ名の集合 def __setattr__( self , name, value): if isinstance (value, Parameter): # Parameter型が設定されたら self ._params.add(name) # パラメータ名の集合にその名前を追加 super ().__setattr__(name, value) def __call__( self , * inputs): outputs = self .forward( * inputs) if not isinstance (outputs, tuple ): outputs = (outputs,) self .inputs = [weakref.ref(x) for x in inputs] self .outputs = [weakref.ref(y) for y in outputs] return outputs if len (outputs) > 1 else outputs[ 0 ] # 出力が一つの場合はそのまま返す def forward( self , inputs): raise NotImplementedError() def params( self ): for name in self ._params: yield self .__dict__[name] # ここでパラメータを順次返す def cleargrads( self ): for param in self .params(): param.cleargrad() # すべてのパラメータの勾配をクリアする |
yieldという見慣れないものが出てきましたが、これはgeneratorとしてこの関数を使うことができるようになる。という理解でいったんOKです。cleargradsにあるようなfor(in self.params())文を書けるようになる。といった理解。
Linearクラス(Layer継承の具体例)
linear_simpleをLayerクラスを継承する形でまじめに実装したものです。重みWとバイアスbがParameter型になっています。(更新の対象)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | class Linear(Layer): def __init__( self , out_size, nobias = False , dtype = np.float32, in_size = None ): super ().__init__() self .in_size = in_size # in_sizeは宣言時は不定でもよい self .out_size = out_size self .dtype = dtype self .W = Parameter( None , name = 'W' ) # Parameter型で作る if self .in_size is not None : self ._init_W() if nobias: self .b = None else : self .b = Parameter(np.zeros(out_size, dtype = dtype), name = 'b' ) # これもParameter def _init_W( self ): I, O = self .in_size, self .out_size W_data = np.random.randn(I, O).astype( self .dtype) * np.sqrt( 1 / I) self .W.data = W_data def forward( self , x): if self .W.data is None : # 最初のforward時にサイズを決める self .in_size = x.shape[ 1 ] # 入力ベクトルのサイズ(0番目はバッチサイズ) self ._init_W() y = F.linear(x, self .W, self .b) return y |
これで例えば
1 2 3 4 5 6 7 8 | l1 = Linear( 10 ) l2 = Linear( 1 ) def predict(x): y = l1(x) y = F.sigmoid(y) y = l2(y) return y |
みたいにして中間層10ニューロン、出力1の全結合のニューラルネットワークができます。記述のスタイルもとても目になじみがあります。
続・Layerクラス
Layerの入れ子を許容する形にします。Layerの中のLayerをパラメータの1つとみなして複数のLayerやパラメータを持つ大きなLayerを作れるようにします。修正箇所は既存のクラスに対して2か所だけ。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class Layer: : def __setattr__( self , name, value): if isinstance (value, (Parameter, Layer)): # ここでParameterと並列にLayerも self ._params.add(name) # Layerの追加も許容 super ().__setattr__(name, value) : def params( self ): for name in self ._params: obj = self .__dict__[name] if isinstance (obj, Layer): # もしLayerだったら yield from obj.params() # そのLayerのparamを返す else : yield obj |
これでLayerの入れ子が作れて、複数のLayerで束ねられたLayerが作れるようになります。上記のモデルであれば以下のように書き換えられます。
1 2 3 4 5 6 7 8 9 | model = Layer() model.l1 = Linear( 10 ) model.l2 = Linear( 1 ) def predict(model, x) y = model.l1(x) y = F.sigmlid(y) y = model.l2(y) return y |
このコードでは一見ご利益が見えないですが、model.params()で層下のすべてのパラメータにアクセスできたり、model.cleargrads()ですべての勾配がリセットされるのはとても便利です。
あとこのクラスを継承した新しいクラスを作ることで、そのクラスにネットワークモデルすべてを記述することができるようになります。
1 2 3 4 5 6 7 8 9 10 | class TwoLayerNet(Layer): def __init__( self , hidden_size, out_size): super ().__init__() self .l1 = L.Linear(hidden_size) self .l2 = L.Linear(out_size) def forward( self , x): y = F.sigmoid( self .l1(x)) y = self .l2(y) return y |
Modelクラス
Layerクラスを継承し、plot関数による視覚化機能を追加した新しいクラス。今後はこのクラスを使うのかな?わざわざ新しい継承クラスにする必要あったのかな?
MLPクラス(Model継承の具体例)
Multi-Layer Perceptronの略で、任意層のモデルを簡単に作るためのユーティリティ的クラスですね。確かに便利。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class MLP(Model): def __init__( self , fc_output_sizes, activation = F.sigmoid): super ().__init__() self .activation = activation self .layers = [] for i, out_size in enumerate (fc_output_sizes): layer = L.Linear(out_size) setattr ( self , 'l' + str (i), layer) # l0,l1,l2...というレイヤ self .layers.append(layer) def forward( self , x): for l in self .layers[: - 1 ]: # 最後の一つは出力層(活性化関数不要) x = self .activation(l(x)) return self .layers[ - 1 ](x) |
これで、mode=MLP((10, 20, 30, 40, 1))みたいにして簡単に5層のモデルが作れてしまいます。
Optimizerクラス
これまで勾配降下法しか出てきませんでしたが、それ以外の最適化手法も簡単に使えるように共通手続きのモジュールにし、簡単に変更できる仕組みにします。
この最適化手法を実装するにあたりその基底クラスがこのOptimizerクラスになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class Optimizer: def __init__( self ): self .target = None def setup( self , target): self .target = target return self def update( self ): params = [p for p in self .target.params() if p.grad is not None ] for param in params: # ここで更新 self .update_one(param) def update_one( self , param): # 継承クラスに任せる raise NotImplementedError() |
これを継承したSGDとMomentumSGDのコードが以下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class SGD(Optimizer): def __init__( self , lr = 0.01 ): super ().__init__() self .lr = lr def update_one( self , param): param.data - = self .lr * param.grad.data class MomentumSGD(Optimizer): def __init__( self , lr = 0.01 , momentum = 0.9 ): super ().__init__() self .lr = lr self .momentum = momentum self .vs = {} def update_one( self , param): v_key = id (param) if v_key not in self .vs: xp = cuda.get_array_module(param.data) self .vs[v_key] = xp.zeros_like(param.data) v = self .vs[v_key] v * = self .momentum # この記述でself.vs[]の中身も変わる(*=を使っているから) v - = self .lr * param.grad.data # ここも。結果self.vs[]も更新される。 param.data + = v |
完全に使い勝手が同じでパラメータ最適化方法が違うだけ。ほかの最適化手法を試したい場合は同じようにOptimizerを継承して作ることでほかの最適化手法が簡単に試せます。
1 2 3 4 5 6 | model = MLP(...) optimizer = SGD(lr) # ここを好きな最適化法へ optimizer.setup(model) for iter in range (max_iter): : optimizer.update() |
これでOK。
ここまでで、Parameter, Layer, Optimizerと、Layerを継承した具体的なModel, Linear, MLPクラス、そしてOptimizerを継承した具体的なSGD、MomentumSGDの順で作ってきました。この先はクラス分類に向けて複数の便利関数を作った後、データ読み込みの定番クラスを作っていきます。
スライス関数
Variableクラスにスライス機能(微分機能付き)を追加しています。詳細は付録。__getitem__という特殊関数でそれが実現できるようです。この関数を実装していると、
1 2 3 | a = Variable(...) b = a[x] c = a[ 2 : 5 ] |
のような記述で要素にアクセスできるようになるようです。
ソフトマックス+交差エントロピー関数
ソフトマックスに関しての解説は保留。またこのコードで出てくるlogやclipもすべてVariableクラスを受けて微分を保持する関数として作りこんでいる前提です。
1 2 3 4 5 6 7 8 9 | def softmax_cross_entropy_simple(x, t): # tはone-hotではなく正解index x, t = as_variable(x), as_variable(t) N = x.shape[ 0 ] p = softmax(x) p = clip(p, 1e - 15 , 1.0 ) # To avoid log(0) log_p = log(p) # 微分実装済みlog tlog_p = log_p[np.arange(N), t.data] # ここがポイント y = - 1 * sum (tlog_p) / N return y |
xは2次元(複数のsoftmax結果)で、tは複数の結果のindexが入っている想定。
ポイントとなる箇所では、xが4×2で、tが4の配列での場合、x[0~3, t[0~3]]の中身を順に1次元配列に代入する動きになります。
accuracy関数
正答率を算出する関数です。教師データとの一致をみてその比率を出すものです。
1 2 3 4 5 6 7 | def accuracy(y, t): y, t = as_variable(y), as_variable(t) pred = y.data.argmax(axis = 1 ).reshape(t.shape) # 推論結果の最大indexを返す result = (pred = = t.data) # 教師データと同じ個数(True or False) acc = result.mean() # Trueを1として平均 return Variable(as_array(acc)) |
softmaxしなくても最大値は最大値なので、そのindexを出して、それが教師データと一致していれば正解。ということになります。その比率。
Datasetクラス
データの読み込み取り出しのためのクラスです。いろいろなデータを読み込めるようにデータセットに対してもI/Fを決めてやります。メモリに収まらない巨大なデータであっても内部でファイルから逐次読み込むようなそんな動的な動きを入れて、外からは同じIFでデータが取り出せます。
またこれらのデータに対して行う事前に決めた前処理を行う関数設定も同じI/Fで実装させます。具体的にはデータ拡張のようなそんな前処理が考えられます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | class Dataset: def __init__( self , train = True , transform = None , target_transform = None ): self .train = train self .transform = transform # 前処理関数 self .target_transform = target_transform # 前処理関数 if self .transform is None : self .transform = lambda x: x if self .target_transform is None : self .target_transform = lambda x: x self .data = None self .label = None self .prepare() def __getitem__( self , index): assert np.isscalar(index) if self .label is None : return self .transform( self .data[index]), None else : return self .transform( self .data[index]),\ self .target_transform( self .label[index]) def __len__( self ): return len ( self .data) def prepare( self ): pass |
メモリに全展開可能なシンプルなデータであれば、prepareで一気にself.dataとself.labelに読み込んでしまえばそれで実装は終わりです。
このクラスを継承していろいろなデータセットを扱うことができるようになります。MNISTとかもこのクラスを継承して取り出せるようにしていきます。
DataLoaderクラス
for x in obj:の構文で先頭から順次ミニバッチ単位のデータを取り出すクラスです。必要に応じてシャッフルも行います。前述のDatasetを継承したクラスを受けてその中身を順次nextで取り出す仕組みです。np.arrayで返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | class DataLoader: def __init__( self , dataset, batch_size, shuffle = True ): self .dataset = dataset self .batch_size = batch_size self .shuffle = shuffle self .data_size = len (dataset) self .max_iter = math.ceil( self .data_size / batch_size) self .reset() def reset( self ): self .iteration = 0 if self .shuffle: self .index = np.random.permutation( len ( self .dataset)) # シャッフル else : self .index = np.arange( len ( self .dataset)) def __iter__( self ): return self def __next__( self ): if self .iteration > = self .max_iter: self .reset() raise StopIteration i, batch_size = self .iteration, self .batch_size batch_index = self .index[i * batch_size:(i + 1 ) * batch_size] batch = [ self .dataset[i] for i in batch_index] # データセットから取り出し x = np.array([example[ 0 ] for example in batch]) t = np.array([example[ 1 ] for example in batch]) self .iteration + = 1 return x, t def next ( self ): return self .__next__() |
これで使う側はだいぶシンプルな記述で済みます。ここまでの便利関数・クラスを使ってMNISTの学習をさせたものが以下になります。すごく見やすい。そしてPytorchとか使っているとなじみのある記述に見えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | max_epoch = 5 batch_size = 100 hidden_size = 1000 train_set = dezero.datasets.MNIST(train = True ) # datasetを継承したMNIST-dataset test_set = dezero.datasets.MNIST(train = False ) train_loader = DataLoader(train_set, batch_size) # 上述のままのDataLoader test_loader = DataLoader(test_set, batch_size, shuffle = False ) #model = MLP((hidden_size, 10)) #optimizer = optimizers.SGD().setup(model) model = MLP((hidden_size, hidden_size, 10 ), activation = F.relu) # 活性化はrelu optimizer = optimizers.Adam().setup(model) # 最適化はAdam for epoch in range (max_epoch): sum_loss, sum_acc = 0 , 0 for x, t in train_loader: # 学習過程 y = model(x) loss = F.softmax_cross_entropy(y, t) acc = F.accuracy(y, t) model.cleargrads() loss.backward() optimizer.update() sum_loss + = float (loss.data) * len (t) sum_acc + = float (acc.data) * len (t) print ( 'epoch: {}' . format (epoch + 1 )) print ( 'train loss: {:.4f}, accuracy: {:.4f}' . format ( sum_loss / len (train_set), sum_acc / len (train_set))) sum_loss, sum_acc = 0 , 0 with dezero.no_grad(): # 勾配保持OFF(微分しないから) for x, t in test_loader: # テスト過程 y = model(x) loss = F.softmax_cross_entropy(y, t) # Loss出さなきゃいらない acc = F.accuracy(y, t) sum_loss + = float (loss.data) * len (t) sum_acc + = float (acc.data) * len (t) print ( 'test loss: {:.4f}, accuracy: {:.4f}' . format ( sum_loss / len (test_set), sum_acc / len (test_set))) |
だいぶ長かったですが、ここまでクラスがそろうとPytorch等の通常のフレームワークで書かれたニューラルネットワークのソースコードと遜色ないものに見えます。
LayerやOptimizer、Dataset、DataLoaderがそれぞれどのような役割をしていて、中でどのような動きをしているかだいぶわかった気になります。設計思想はどのフレームワークも似ているのだなぁと思いました。
コメント