ウェブサイト検索

PyTorch 101、グラフの理解、自動微分とAutograd


紹介

PyTorchは、最も優れたPythonの深層学習ライブラリの一つです。深層学習研究の第一選択肢であり、日が経つにつれて、ますます多くの企業や研究機関がこのライブラリを採用しています。

このチュートリアルのシリーズでは、PyTorchについて、そしてそれを取り巻くライブラリやツールのエコシステムをどのように最適に使用するかを紹介します。最初に基本的な構成要素をカバーし、その後、カスタムアーキテクチャを迅速にプロトタイピングする方法について説明します。最後に、コードのスケーリング方法や、問題が発生した場合のデバッグ方法に関するいくつかの記事で締めくくります。

この投稿のコード(他の投稿のコードも)をこちらのGitHubリポジトリで取得できます。

前提条件

  1. 連鎖律
  2. 深層学習の基本理解
  3. PyTorch 1.0

自動微分

多くのPyTorchのチュートリアルシリーズは、基本的な構造についての初歩的な議論から始まります。しかし、私はまず自動微分についての議論から始めたいと思います。

自動微分は、PyTorchだけでなく、すべての深層学習ライブラリの基本要素です。私の意見では、PyTorchの自動微分エンジンであるAutogradは、自動微分がどのように機能するかを理解するための素晴らしいツールです。これにより、PyTorchだけでなく、他の深層学習ライブラリもより良く理解できるようになるでしょう。

現代のニューラルネットワークアーキテクチャは、数百万の学習可能なパラメータを持つことがあります。計算的な観点から見ると、ニューラルネットワークのトレーニングは2つのフェーズから成り立っています:

  1. 損失関数の値を計算するためのフォワードパス。
  2. 学習可能なパラメータの勾配を計算するための逆伝播。

順伝播は非常に単純です。ある層の出力が次の層への入力となり、そのように続いていきます。

逆伝播は少し複雑で、損失関数に対する重みの勾配を計算するために連鎖律を使用する必要があります。

おもちゃの例

非常に単純な神経ネットワークを考えましょう。それはわずか5つのニューロンから成り立っています。私たちの神経ネットワークは次のようになります。

とてもシンプルなニューラルネットワーク

以下の方程式は私たちの神経ネットワークを説明しています。

b=w_1 * ac=w_2 * ad=w_3 * b + w_4 * cL=10 - d

各学習可能なパラメータ $w$ の勾配を計算しましょう。

$$\frac{\partial{L}}{\partial{w_4}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_4}} $$$$\frac{\partial{L}}{\partial{w_3}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_3}} $$$$\frac{\partial{L}}{\partial{w_2}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{c}} * \frac{\partial{c}}{\partial{w_2}} $$$$\frac{\partial{L}}{\partial{w_1}}=\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{b}} * \frac{\partial{b}}{\partial{w_1}} $$

これらの勾配はすべて、連鎖律を適用することによって計算されています。上記の方程式の右辺にあるすべての個別の勾配は、勾配の分子が分母の明示的な関数であるため、直接計算できます。

計算グラフ

私たちは、ネットワークが非常にシンプルだったので、手動で勾配を計算することができました。もし152層のネットワークがあったらどうなるでしょうか。または、ネットワークに複数のブランチがあった場合はどうでしょうか。

ニューラルネットワークを実装するためにソフトウェアを設計する際、私たちはアーキテクチャの種類に関係なく、シームレスに勾配を計算できる方法を考案したいと思っています。これにより、プログラマーはネットワークに変更が加えられた際に手動で勾配を計算する必要がなくなります。

私たちはこのアイデアを計算グラフと呼ばれるデータ構造の形で強化します。計算グラフは、上の画像で作成したグラフの図に非常に似ています。しかし、計算グラフのノードは基本的にオペレーターです。これらのオペレーターは基本的に数学的オペレーターですが、一つのケース、すなわちユーザー定義変数の作成を表す必要がある場合を除きます。

グラフの明確さのために、葉変数 $a, w_1, w_2, w_3, w_4 $も示していることに注意してください。しかし、これらは計算グラフの一部ではないことに注意が必要です。我々のグラフにおいて、これらが示すのは、前回説明した例外としてのユーザー定義変数の特別なケースです。

非常にシンプルなニューラルネットワークの計算グラフ

変数 b,c および d は数学的操作の結果として生成されるのに対し、変数 a, w1, w2, w3 および w4 はユーザーによって初期化されます。これらは数学演算子によって生成されていないため、作成に対応するノードはその名前自体で表されます。これはグラフ内のすべての リーフ ノードに当てはまります。

勾配を計算する

さて、計算グラフを使用して勾配を計算する方法を説明する準備が整いました。

計算グラフの各ノードは、葉ノードを除いて、いくつかの入力を取り、出力を生成する関数と見なすことができます。変数dを$ w_4c$と$w_3b$から生成するグラフのノードを考えてみましょう。したがって、次のように書くことができます。

$$ d=f(w_3b , w_4c) $$

dは関数f(x,y)=x + yの出力です。

これで、入力に対する $f$の勾配を簡単に計算できます。具体的には、$\frac{\partial{f}}{\partial{w_3b}}$と $\frac{\partial{f}}{\partial{w_4c}}$(どちらも 1 です)。次に、ノードに入るエッジにそれぞれの勾配をラベル付けしてください。

局所勾配

私たちは全体のグラフに対してそれを行います。グラフはこのように見えます。

計算グラフにおける逆伝播

以下に、このグラフの任意のノードの損失 $L$ に対する導関数を計算するアルゴリズムを説明します。ここで、$\frac{\partial{f}}{\partial{w_4}}$ の導関数を計算したいとしましょう。

  1. 最初に、d から $ w_4 $ へのすべての可能な経路をたどります。
  2. そのような道は一つだけです。
  3. この道に沿ったすべての辺を掛け算します。

もし見ると、製品は私たちが連鎖律を使って導き出した表現と正確に同じです。変数に対する経路が L から複数ある場合、それぞれの経路に沿ったエッジを掛け算してから、それらを足し合わせます。例えば、$\frac{\partial{L}}{\partial{a}}$は次のように計算されます。

∂f/∂w₄=∂L/∂d * ∂d/∂b * ∂b/∂a + ∂L/∂d * ∂d/∂c * ∂c/∂a

PyTorch 自動微分

計算グラフが何なのか分かったので、PyTorchに戻り、上記がPyTorchでどのように実装されているかを理解しましょう。

テンソル

テンソルは、PyTorchの基本的な構成要素であるデータ構造です。テンソルはnumpy配列に非常に似ていますが、numpyとは異なり、テンソルはGPUの並列計算機能を活用するように設計されています。多くのテンソルの構文はnumpy配列のものと似ています。

In [1]:  import torch

In [2]: tsr = torch.Tensor(3,5)

In [3]: tsr
Out[3]: 
tensor([[ 0.0000e+00,  0.0000e+00,  8.4452e-29, -1.0842e-19,  1.2413e-35],
        [ 1.4013e-45,  1.2416e-35,  1.4013e-45,  2.3331e-35,  1.4013e-45],
        [ 1.0108e-36,  1.4013e-45,  8.3641e-37,  1.4013e-45,  1.0040e-36]])

単独では、Tensorはnumpyのndarrayのようなものです。高速な線形代数のオプションを行うことができるデータ構造です。これらの操作に対応するグラフをPyTorchに作成させたい場合は、Tensorのrequires_grad属性をTrueに設定する必要があります。

ここではAPIが少し混乱を招くことがあります。PyTorchではテンソルを初期化する方法がいくつかあります。いくつかの方法では、コンストラクタ自体でrequires_gradを明示的に定義できる一方で、他の方法ではテンソルの作成後に手動で設定する必要があります。

>> t1 = torch.randn((3,3), requires_grad = True) 
>> t2 = torch.FloatTensor(3,3) # No way to specify requires_grad while initiating 
>> t2.requires_grad = True

requires_gradは伝染性があります。これは、他のTensorに対して操作することでTensorが作成されると、生成されたTensorのrequires_gradがTrueに設定されることを意味します。これは、作成に使用されたテンソルのうち、少なくとも1つがrequires_gradをTrueに設定されている場合に限ります。

各テンソルには、変数を作成する数学的演算子を指すgrad_fnという属性があります。requires_gradがFalseに設定されている場合、grad_fnはNoneになります。

私たちの例では、$d=f(w_3b, w_4c) $のように、dのgrad関数は加算演算子になります。これは、fが入力を加算するためです。加算演算子は、私たちのグラフでdを出力するノードでもあることに注意してください。もし私たちのTensorが葉ノード(ユーザーによって初期化された)であるなら、grad_fnもNoneになります。

import torch 
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a 
c = w2*a
d = w3*b + w4*c 
L = 10 - d
print("The grad fn for a is", a.grad_fn)
print("The grad fn for d is", d.grad_fn)

上記のコードを実行すると、次の出力が得られます。

The grad fn for a is None
The grad fn for d is <AddBackward0 object at 0x1033afe48>

メンバー関数is_leafを使用して、変数がリーフTensorであるかどうかを判断できます。

機能

PyTorchのすべての数学演算は、torch.nn.Autograd.Functionクラスによって実装されています。このクラスには、注目すべき2つの重要なメンバ関数があります。

最初のものは、それの前方関数であり、それは単に入力を使用して出力を計算します。

backward 関数は、その前のネットワーク部分から来る勾配を受け取ります。見ての通り、関数 f から逆伝播される勾配は基本的に その前の層から f に逆伝播される勾配 に f の出力のローカル勾配をその入力に対して掛けたもの です。これがまさに backward 関数の役割です。

私たちの例 $$ d=f(w_3b , w_4c) $$ を使って、もう一度理解しましょう。

  1. dはここで私たちのTensorです。grad_fnは<ThAddBackward>です。これは基本的に加算操作です。なぜなら、dを作成する関数が入力を加算するからです。
  2. それの grad_fn の forward 関数は、入力として $w_3b$ と $w_4c$ を受け取り、それらを加算します。この値は基本的に d に保存されます。
  3. <ThAddBackward> の backward 関数は基本的に、より遠い層からの入力勾配を入力として受け取ります。これは基本的に $\frac{\partial{L}}{\partial{d}}$ であり、L から d へ続くエッジに沿って伝わります。この勾配はまた、L の d に対する勾配でもあり、d の grad 属性に保存されています。d.grad を呼び出すことでアクセスできます。
  4. 次に、局所的な勾配を計算します $\frac{\partial{d}}{\partial{w_4c}}$ と $\frac{\partial{d}}{\partial{w_3b}}$.
  5. 次に、逆関数は入ってくる勾配を局所的に計算された勾配とそれぞれ掛け算し、"送信します"その勾配を入力のgrad_fnの逆伝播メソッドを呼び出すことで入力に送ります。
  6. 例えば、<ThAddBackward> に関連する d の backward 関数は、$w_4*c$の grad_fn の backward 関数を呼び出します(ここで、$w_4*c$は中間テンソルであり、その grad_fn は <ThMulBackward> です)。backward 関数が呼び出されるとき、勾配 $\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_4c}}$が入力として渡されます。
  7. 今、変数 $w_4*c$に対して、$\frac{\partial{L}}{\partial{d}} * \frac{\partial{d}}{\partial{w_4c}}$は、ステップ3の $d$に対しての $\frac{\partial{L}}{\partial{d}}$のように、入ってくる勾配となります。このプロセスは繰り返されます。

アルゴリズム的には、計算グラフを使ってバックプロパゲーションがどのように行われるかは次のようになります。(実際の実装ではなく、代表例です)

def backward(self, incoming_gradients):
    # Set the gradient for the current tensor
    self.Tensor.grad = incoming_gradients

    # Loop through the inputs to propagate the gradient
    for inp in self.inputs:
        if inp.grad_fn is not None:
            # Compute new incoming gradients for the input
            new_incoming_gradients = incoming_gradients * local_grad(self.Tensor, inp)
            # Recursively call backward on the input
            inp.grad_fn.backward(new_incoming_gradients)

ここで、self.Tensorは基本的に、私たちの例でdであったAutograd.Functionによって作成されたTensorです。

上記にて入ってくる勾配および局所勾配について説明しました。

私たちのニューラルネットワークで導関数を計算するために、一般的に損失を表すTensorに対してbackwardを呼び出します。それから、損失のgrad_fnを表すノードから始めて、グラフをさかのぼります。

上に説明したように、backward 関数は、遡っている間にグラフ全体で再帰的に呼び出されます。一度リーフノードに到達すると、grad_fn が None であるため、そのパスを遡るのを停止します。

ここで注意すべきことは、PyTorchがベクトル値のテンソルに対してbackward()を呼び出すとエラーを返すということです。これは、スカラー値のテンソルに対してのみbackwardを呼び出せることを意味します。私たちの例では、aがベクトル値のテンソルであると仮定し、backwardをLに対して呼び出すと、エラーが発生します。

import torch 
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a 
c = w2*a
d = w3*b + w4*c 
L = (10 - d)
L.backward()

上記のスニペットを実行すると、次のエラーが発生します。

RuntimeError: grad can be implicitly created only for scalar outputs

これは、勾配が定義によりスカラー値に関して計算できるためです。ベクトルを別のベクトルに関して正確に微分することはできません。そのような場合に使われる数学的な対象はヤコビアンと呼ばれ、この議論はこの記事の範囲を超えています。

これを克服する方法は二つあります。

上記のコードで設定を少し変更して、Lをすべての誤差の合計にすれば、私たちの問題は解決されます。

import torch 
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a 
c = w2*a
d = w3*b + w4*c 

Replace L = (10 - d) by 
L = (10 -d).sum()
L.backward()

それが完了したら、Tensorのgrad属性を呼び出すことで勾配にアクセスできます。

第二の方法は、何らかの理由でベクトル関数に対して必ずbackwardを呼び出さなければならない場合、呼び出そうとしているテンソルのサイズと形状に合わせたtorch.onesを渡すことです。

Replace L.backward() with 
L.backward(torch.ones(L.shape))

注意してください。backwardは入力として入ってくる勾配を受け取るようになっていました。上記のようにすると、backwardは入ってくる勾配をLと同じサイズの1のテンソルだと考え、逆伝播が可能になります。

このようにして、すべてのテンソルに対して勾配を持つことができ、私たちの選択した最適化アルゴリズムを使ってそれらを更新することができます。

w1 = w1 - learning_rate * w1.grad

それに続いて。

PyTorchのグラフはTensorFlowのグラフとどう違うのか

PyTorchは動的計算グラフと呼ばれるものを作成します。これは、グラフがその場で生成されることを意味します。

変数のforward関数が呼ばれるまで、グラフにはそのTensor (それのgrad_fn)に対するノードは存在しません。

a = torch.randn((3,3), requires_grad = True)   #No graph yet, as a is a leaf
w1 = torch.randn((3,3), requires_grad = True)  #Same logic as above
b = w1*a   #Graph with node `mulBackward` is created.

グラフは、多くの Tensors の forward 関数が呼び出される結果として作成されます。そうすると、グラフと中間値(後で勾配を計算するために使用される)の非リーフノードのためにバッファが割り当てられます。 backward を呼び出すと、勾配が計算される際に、これらのバッファ(非リーフ変数用)は本質的に解放され、グラフは 破棄されます (つまり、勾配を計算するための値を保持するバッファがなくなったので、逆伝播できないということです)。

次回は、同じテンソルのセットに対してforwardを呼び出しますが、前回の実行からのリーフノードバッファは共有され、非リーフノードバッファは再作成されます。

非葉ノードを持つグラフでbackwardを1回以上呼び出すと、次のエラーが発生します。

RuntimeError: Trying to backward through the graph a second time, but the buffers have already been freed. Specify retain_graph=True when calling backward the first time.

これは、非葉バッファが最初にbackward()が呼ばれると破壊されるためです。そして、そのため、backwardが2回目に呼ばれると、葉に移動するパスが存在しません。この非葉バッファの破壊動作を取り消すには、backward関数にretain_graph=True引数を追加してください。

loss.backward(retain_graph = True)

上記の作業を行えば、同じグラフを再度バックプロパゲートでき、勾配が蓄積されます。つまり、次回バックプロパゲートするときには、勾配が前回のバックパスで保存されているものに追加されます。

これは、プログラムを実行する前にグラフが宣言されるTensorFlowが使用する静的計算グラフとは対照的です。次に、事前に定義されたグラフに値を供給することによって、グラフが「実行される」ことになります。

動的グラフパラダイムでは、ネットワークアーキテクチャをランタイム中に変更することができ、コードの一部が実行されるときにのみグラフが作成されます。

これは、プログラムの実行中にグラフを再定義できることを意味します。事前に定義する必要がないためです。

しかし、これは静的グラフでは不可能です。静的グラフはプログラムを実行する前に作成され、後で単に実行されます。

動的グラフは、エラーの発生源を特定するのが簡単なため、デバッグをはるかに容易にします。

商売のコツ

勾配を必要とする

Tensorクラスの属性です。デフォルトでは、Falseになっています。これは、いくつかのレイヤーをフリーズして、トレーニング中にパラメータの更新を停止する必要がある場合に便利です。requires_gradをFalseに設定するだけで、これらのTensorsは計算グラフに参加しなくなります。

したがって、勾配はそれらのレイヤーや、勾配の流れにこれらのレイヤーに依存するレイヤーには伝播されません。requires_gradがTrueに設定されると、requires_gradは感染性があり、演算のオペランドの1つがrequires_gradをTrueに設定している場合、その結果も同様になります。

torch.no_grad()

勾配を計算する際には、入力値や中間特徴をキャッシュする必要があります。なぜなら、それらが後で勾配を計算するために必要になる可能性があるからです。

変数 $b=w_1*a $の入力 $w_1 $と $a $に関する勾配はそれぞれ $a $と $w_1 $です。バックワードパス中の勾配計算のためにこれらの値を保存する必要があります。これはネットワークのメモリ使用量に影響を与えます。

推論を行っている間、勾配を計算することはなく、したがってこれらの値を保存する必要もありません。実際、推論中にはグラフを作成する必要はなく、それは無駄なメモリの消費につながります。

PyTorchは、この目的のためにtorch.no_gradというコンテキストマネージャを提供しています。

with torch.no_grad:
        inference code goes here 

このコンテキストマネージャーの下で実行される操作に対するグラフは定義されていません。

結論

Autograd と計算グラフの仕組みを理解することで、PyTorch の使用がずっと楽になるでしょう。基礎がしっかりしているので、次の投稿ではカスタムの複雑なアーキテクチャの作成方法や、カスタムデータパイプラインの作成方法、さらにはその他の興味深い内容について詳しく説明します。

さらなる読書

  1. 連鎖律
  2. バックプロパゲーション

関連記事: