はじめに
下記のアンケートの結果PyTorchの記事を書きます。期待されているPyTorchの基本が何かはよく分かりませんが、公式チュートリアルで手薄になりがちなところ(NNを構築!とかは十分に例があるので)を抑えとこうと思いました。
知りたいのは(記事のテーマ探してます)
— HELLO CYBERNETICS (@ML_deep) July 16, 2019
以下
import torch
を前提とします。
Tensorの生成
Tensorのメモリ確保
下記のコードはTensorのメモリを確保するのに使えます。
値は本当に適当に決まりますが、後に見る torch.zeros
やtorch.ones
などよりも負荷が低いようです。
x = torch.empty(2, 4) print(x) # tensor([[5.6132e-37, 0.0000e+00, 4.4842e-44, 0.0000e+00], # [nan, 0.0000e+00, 1.1039e-05, 1.0563e-05]])
また、tensorのデータ型を指定することもできます。
例えば、torch.long
なら下記のように書きます。
x = torch.empty(2, 4, dtype=torch.long)
すべての要素が $0$ のTensor
下記のように書きます。
x = torch.zeros(2, 4) print(x) # tensor([[0., 0., 0., 0.], # [0., 0., 0., 0.]])
その後
x[1, 2] = 2. print(x) # tensor([[0., 0., 0., 0.], # [0., 0., 2., 0.]])
などとして使うことが多いでしょう。
すべての要素が $1$ のtensor
x = torch.ones(3, 3) print(x) # tensor([[1., 1., 1.], # [1., 1., 1.], # [1., 1., 1.]])
各要素が $[0, 1]$ の一様分布から生成されるtensor
x = torch.rand(2, 4) print(x) # tensor([[0.0233, 0.0479, 0.4627, 0.0186], # [0.2796, 0.2416, 0.5303, 0.5245]])
となります。$[0, 3]$ の区間を利用したければ
x = 3 * torch.rand(2, 4)
と書けますし、 $[-2, 7]$ などを使いたければ
x = 9 * torch.rand(2, 4) - 2
などと書きます。9 * torch.rand(2, 4)
が $[0, 9]$ の区間を扱い、そのあと $-2$ することで $[-2, 7]$ とスライド移動しています。
各要素が 平均 $0$ 標準偏差 $1$ の正規分布から生成されるtensor
x = torch.randn(2, 4) print(x) # tensor([[-0.0955, -0.4452, 0.8144, 0.5917], # [ 0.6073, -0.8962, -1.4670, -1.0904]])
と書けます。もしも平均 $2$ で標準偏差 $8$ の正規分布から生成される tensorを扱いたい場合は
x = 2 + 8 * torch.randn(2, 4)
と書くことができます。もしも 分散 $7$ の正規分布を扱いたい場合は、 $\sqrt 7$ を掛ければよいということです。
Pythonやnumpyの型からtorch.Tensorの型に変換する
PyTorchの関数の多くは、torch.Tensor
型を引数として受け取ります。例えば
torch.sqrt(7)
はエラーを吐いてしまうのです。そこで
x = torch.tensor(7.0) print(x) # tensor(7.)
などとしてやる必要があります。学習データなどもすべて torch.Tensor
として与えなければならないことに注意しましょう。(Kerasはnumpyを受け取っても、自動的にtf.Tensor
として扱う機能がありますが、これは元々バックエンドとしてTheanoやMXnetも選択肢として入っていたため、ライブラリの設計段階で与えられる型が決まっておらず、numpyで受け取ることを前提に作られているからです)
既存のTensorの形を引用して新しいTensorを作る
例えば既に (5, 6)
の形をしたtensorがあるとしましょう。このとき、それと同じサイズの、各要素が一様分布から生成される新しいtensorを作りたい場合は下記のように書きます。
x = torch.empty(5, 6) y = torch.rand_like(x) print(y) # tensor([[0.6537, 0.6350, 0.9114, 0.4744, 0.3637, 0.2449], # [0.4587, 0.9934, 0.6232, 0.9400, 0.3368, 0.4670], # [0.9925, 0.0646, 0.0664, 0.6988, 0.2416, 0.3122], # [0.7196, 0.1046, 0.3365, 0.9340, 0.4746, 0.7192], # [0.9666, 0.5353, 0.2914, 0.9022, 0.7388, 0.3535]])
これは意外と便利なのでぜひ覚えておきましょう。例えば、各成分にガウスノイズを独立に乗せたい場合は
clean_tensor = torch.tensor([[2, 3, 4], [1, 1, 5]], dtype=torch.float) noisy_tensor = clean_tensor + torch.randn_like(clean_tensor)
などと書けば良いことになります。他にも torch.ones_like()
や torch.zeros_like()
もあります。
Tensorの計算
加減乗除
下記の z
の計算はすべて同じ結果となります。
x = torch.tensor([2., 1.]) y = torch.tensor([3., 7.]) z = x + y z = torch.add(x, y) z = x.add(y) z = y.add(x)
また、下記のように書くこともできます。
x = torch.tensor([2., 1.]) y = torch.tensor([3., 7.]) z = torch.empty_like(x) torch.add(x, y, out=z)
このとき、計算の前にz
は何らかの方法でメモリが確保されている前提となります。また torch.add
によって z
の値が書き換えられていることにも注意しましょう(また、返り値としても出てくる)。
引き算は sub
で掛け算は mul
で割り算は div
として同じようになります。
またこれらの計算はすべて要素毎に実施されることに注意しましょう。(したがって同じサイズのtensor同士で用いる)
tensorの形
形の確認
x = torch.empty(7, 4)
とすれば、(7, 4)
の形のテンソルが作られます。あるテンソル x
の形を確認したい場合はどうすればよいでしょうか。
ひとつは
x.size()
# torch.Size([7, 4])
という方法です。これはメソッドであり、引数として軸を与えることができ
x.size(0) # 7
と返ってきます。
もうひとつは
x.shape
# torch.Size([7, 4])
であり、これはtorch.Tensor
クラスが有するタプルのような属性であり
x.shape[0] # 7
と要素にアクセスすることができます。
形の変更
下記のメソッドで形を変更できます。なんやねんそのメソッドの命名は…?と僕も思いましたが、このメソッドを使うのが通常です。
x = torch.empty(7, 4) x = x.view(14, 2)
このメソッドは、テンソルの要素がメモリ上に連続的に配置されていなければうまく動作しないようで、CNNの層を全結合層に変換する時によく生ずるエラーとなります。テンソルの要素をメモリ上に連続的に配置し直すメソッドとして
x = torch.empty(7, 4) x = x.contiguous()
というメソッドがあります。たいてい、CNNから全結合層につなげるためにテンソルの形状を変更する必要がある場合は、このメモリへの再配置メソッドを利用してx.coutiguous().view(hoge, fuga)
としておくほうが無難でしょう。
PyTorchは当初このような仕様でしたが、いつからか
x = torch.empty(7, 4) x = x.reshape(14, 2)
とできるようになっていました。よく分かりませんが、こちらは contiguous()
メソッド不要です。特に view
の方が速いとかもなさそうです…。
Tensorの微分
自動微分機能はこの手のライブラリの本質的に重要な機能です。 パラメータの学習には勾配を利用するため、勾配を効率よく計算・保持しておく仕組みが各々のライブラリの工夫の点となるでしょう。
微分する宣言
後々に $x$ という変数で偏微分する、すなわち 「$\partial _ x$ を後で計算するぜ!」という場合には、その計算ができるような準備が tensor側に必要となります。
x = torch.tensor([1.], requires_grad=True)
としておくことで、後々 x
がどんな計算に使われるかは分かりませんが、計算された結果を x
で偏微分するための情報を内部に保持するようになります。これは非常に重要なことで、テンソルそれぞれが、「自分が後で誰かを偏微分するんだ」ということを把握しているということです。
$$ y = 2 x ^ 2 $$
を計算してみましょう。
y = 2 * x ** 2
このとき、
$$ \partial _ x y = 4 x $$
です(一変数なので偏微分として書く必要は特に無いのですが、応用上、間違いなく多変数を扱うことになります)。
すると、今の x
の値は $1$ なので、この偏微分の値は $4$ となっているはずです。さて、偏微分の計算を実行するには
y.backward()
とします。ここで「何で微分しますか?」ということを指定する項目が無いことに気づいたでしょうか。実は y
自身が、どんな計算を介して自分の数値が得られているのかを把握しているのです。そして、y
が自分が計算された経歴の中で、偏微分宣言をしている変数(ここでは x
)に対して、「お前の偏微分の値はいくつだぞ」ということを伝えるのが上記の backward()
メソッドになります。
このメソッドの内部では偏微分宣言をしているtensorのインスタンスが持つ grad
という内部変数に偏微分の値を格納してあげています。すなわち、 x
での偏微分値である $4$ は x.grad
に格納されているのです。
x = torch.tensor([1.], requires_grad=True) y = 2 * x ** 2 y.backward() print(x.grad) # tensor([4.])
連鎖規則
さて、ここでニューラルネットワークを扱うために更に重要な応用例があります。
x = torch.tensor([1.], requires_grad=True) y = 2 * x ** 2
例えば引き続き
z = 5 * y
という計算がなされたとしましょう。全体として
$$ \begin{align} y &= 2x ^ 2 \\ z &= 5 y = 10x ^ 2 \end{align} $$
という計算になっています。さてここで
z.backward()
とすると、z
は、偏微分宣言をしている x
に関して、偏微分値がいくつであるかを x.grad
に格納します。ここでは $z = 20x$ ですから、値は x.grad = tensor([20.])
となっています。しかし、実態としては $z = 20x$ という数式を知っているわけではありません。これを直接微分しているのではなく、偏微分の連鎖規則を用いて
$$ \frac{\partial z}{\partial x} = \frac{\partial z}{\partial y} \frac{\partial y}{\partial x} = 5 \times 4 x $$
を計算しています。この連鎖規則が内部で実行されているというのが、この手のライブラリの本質的に重要なことです(理屈は計算グラフだとかバックプロパゲーションだとかで自動微分だとかで調べればいろいろ出てくるでしょう)。
また、Tensorを作成するときだけでなく、既に作成済のTensor X
に関して、偏微分するぞ宣言を X.requires_grad_(True)
と実施できることも覚えておきましょう。
ニューラルネットワーク
さて、ニューラルネットワークの学習とは最も単純には勾配法でニューラルネットワークのパラメータを調整していくことでした。微分が計算できるようになったので、勾配法をスクラッチで実装できますね!
import matplotlib.pyplot as plt plt.style.use("seaborn") X = torch.linspace(0, 1, 1000).reshape(-1, 1) Y = torch.sin(2*3.14*X) plt.plot(X.numpy(), Y.numpy())
というデータを使うことにしましょう。ただのサイン関数です。
ニューラルネットワークのパラメータを準備します。今回はバイアスはなしでいきましょう。 偏微分するぞ宣言を忘れないようにします。
W1 = torch.randn(1, 128).requires_grad_(True) W2 = torch.randn(128, 128).requires_grad_(True) W3 = torch.randn(128, 1).requires_grad_(True)
次にこいつらのパラメータを使って順伝播を計算します。ついでに目標値である Y
との二乗平均誤差も計算してみましょう。
h1 = torch.sigmoid(torch.mm(X, W1)) h2 = torch.sigmoid(torch.mm(h1, W2)) H = torch.torch.mm(h2, W3) error = ((Y - H)**2).mean() print(error) # tensor(9.9839, grad_fn=<MeanBackward0>)
誤差の値は運です。初期化次第で変わりますからね。 一応このときのニューラルネットワークの出力をプロットしておきましょう。
plt.plot(X.numpy(), Y.numpy()) plt.plot(X.numpy(), H.detach().numpy()) plt.legend(["true", "predict"])
全く別物になっていますね。ここから学習で近づけます。
error.backward() W1.data = W1.data - 1e-3*W1.grad.data W2.data = W2.data - 1e-3*W2.grad.data W3.data = W3.data - 1e-3*W3.grad.data W1.grad.detach_().zero_() W2.grad.detach_().zero_() W3.grad.detach_().zero_() print("reset gradients")
ここで注意が必要です。なぜ W1 = W1 - 1e-3*W1.grad
と書かないのでしょうか。実はこうしても値は更新できます。しかし、偏微分するぞ宣言している変数による計算はその計算の経歴を完全に覚えてしまうことを思い出してください。自分で自分を計算して値が変わっているというのは、プログラム上の代入としては良くても、数式の等式で結べるものではありません。そんな経歴を覚えてもらっては困るのです(というか、数学的にどうであれ、後々その微分計算を必要としないような計算は、計算グラフをわざわざ残すのはメモリとCPUの無駄遣いである)。
なので、Tensorの中身であるプロパティの data
にアクセスして、このdata
を直接書き換えます。
そのあと、detach_()
というメソッドが呼ばれていますが、これは計算グラフをリセットするという項目です。これをせずに次の計算をすると、あたかも、先程の計算と連続的に連なっている合成関数のように捉えられてしまいます。一連の計算が終わったことを伝えてあげなければいけません。そして、勾配を zero_()
でリセットしてあげています。ちなみにメソッド名の後ろにあるアンダースコアは、in_place処理であることを示しています(実は X.add(Y)
などに対して X.add_(Y)
とすれば in_place処理になる)。
もう一度順伝播しましょう。
h1 = torch.sigmoid(torch.mm(X, W1)) h2 = torch.sigmoid(torch.mm(h1, W2)) H = torch.torch.mm(h2, W3) error = ((Y - H)**2).mean() print(error) # tensor(6.3288, grad_fn=<MeanBackward0>)
エラーが下がっています。素晴らしい。このときの出力のプロットを先程と同じように見てみると…
ううむ、確かに近づいた…?
このあと、このコードをNotebookで5000回程実行したときのニューラルネットワークの出力がこちら
まだまだ、先は長そうである。しかし、とにもかくにも、順伝播、微分計算、勾配法での更新、勾配リセット(リセットはPyTorchの計算グラフの実装の仕様上の都合である。ちなみに僕はこれが気持ち悪くて、TF2.0の実装のほうが好きである)を繰り返しているだけですね。簡単だ!
そして15000回のCtrl + Enter連打()を終えた後がこちらになります。
最後に
では、今回学習がうまく最後まで行ききらなかったのは何が問題でしょう。モデルの複雑さが足りないのかもしれないし、学習率の問題かもしれません。いろいろな要因が考えられる中で試行錯誤を繰り返す必要があります。
その時にこんなふうにスクラッチで書いていては溜まったものではないでしょう。PyTorchやらTensorFlowやらChainerやらは自動微分を提供するだけでなく、ニューラルネットワークのコンポーネントを予めいろいろ実装してくれています。最適化法も勾配法だけではありません。
試行錯誤を繰り返す上で人間が楽をする手段を与えてくれます。上手に使いましょう。