HELLO CYBERNETICS

深層学習、機械学習、強化学習、信号処理、制御工学、量子計算などをテーマに扱っていきます

【PyTorch入門】Tensorの扱いから単回帰まで

 

 

follow us in feedly

はじめに

PyTorchの基本はNumPyライクな操作に加えて自動微分機能を有したライブラリであるということです(※近年は Google/jax にほぼ NumPy が現れたが…)。とは言っても、全く互換性を保った設計になっているわけではないので、ある程度の慣れが必要になります。

メソッドや関数に説明が必要そうであれば適宜解説を挿入しますが、NumPyの基本的な操作は知っているという前提で説明を進めていきます。

前提として

import torch

とインポートを実施していることとします。

Tesnor型

PyTorchのTensor型についてザッと概観しましょう。このTensor型についてちゃんと操作に慣れてしまうとあとは随分と楽になるはずです。実際、PyTorchの構成は、Tensor型の操作を便利にする関数(torch.nn.functional)や管理を簡単にするクラス(torch.nn.Module)が色々取り揃えられていると言う具合になっていますので、まさにそんな関数やクラスがあれば良いな、というのをTensor型だけを操作しながら感じて貰えれば良いと思います。

dtype

意外とNumPyでデータを扱いつつPyTorchで学習を実施しようとしたときに陥りがちなのが、データの型に関するものです。まずは下記のTensor型の各要素のデータ型を確認してみましょう。

X = torch.tensor([[2., 1.],
                  [5., 3.]])

X.dtype

#-> torch.float32

仮に X = np.array() で同じことをした場合には、データ型はデフォルトでnp.float64 となります。意外とこの型違いによってエラーに嵌ることがあります。例えば一方のTensorに関しては np.ndarray から作成されており、他方がPyTorchで作成された場合に、明示的に型を変更しておかなければ型違いが生ずるという具合です。ちなみに要素に小数点をつけない場合には、torch.int64という型になります。

X = torch.tensor([[2, 1],
                  [5, 3]])

X.dtype
#-> torch.int64

基本的に、PyTorchではtorch.float32torch.int64 が用いられます。連続値が前者でカテゴリカルな離散値が後者という具合です。

X.float()X.long() によってそれぞれ float32int64dtype を変更できるので必ず覚えておきましょう。

その他の重要メソッド

また、その他の重要なメソッドを一挙に紹介しておきます。 いずれも多くの場面で頻出するものであるので、必ず調べなくても使えるようにしておかなければなりません。

empty

# torch.zeros等よりも速い。メソッドではなく関数ですが…。適当にTensorを確保するならばこれが最速。
X = torch.empty(3, 3)
X

#tensor([[1.1348e+29, 0.0000e+00, 0.0000e+00],
#        [0.0000e+00, 0.0000e+00, 0.0000e+00],
#        [0.0000e+00, 0.0000e+00, 0.0000e+00]])

to

cudaデバイスに先程作ったTensorが置かれていることが明示されます。

# to(device) メソッドで指定したdeviceへTensorを"コピーする"。したがってコピー先の参照を渡す必要がある。
X = X.to("cuda")
X
#tensor([[1.1348e+29, 0.0000e+00, 0.0000e+00],
#        [0.0000e+00, 0.0000e+00, 0.0000e+00],
#        [0.0000e+00, 0.0000e+00, 0.0000e+00]], device='cuda:0')

また、下記のようにcpuに戻すことも可能です。

# to("cpu") で cpuに戻せる。
X = X.to("cpu")
X

#tensor([[1.1348e+29, 0.0000e+00, 0.0000e+00],
#        [0.0000e+00, 0.0000e+00, 0.0000e+00],
#        [0.0000e+00, 0.0000e+00, 0.0000e+00]])

それぞれ、X.cuda()X.cpu()という方法も使えます。基本的には device = 'cuda' if torch.cuda.is_available() else 'cpu' と利用するデバイスを変数の形で指定しておき X.to(device) という使い方になるでしょう。一方で cpu 上でしか操作できない内容(例えばNumPyに戻してプロットしたい…とかPost Processをするとか)の場合には当然に cpu に移すに決まっているのですから、そういった場合には X.cpu() などを使うことになります。

numpy

# numpy.ndarray 形式で取り出す
X.numpy()
#array([[1.1348247e+29, 0.0000000e+00, 0.0000000e+00],
#       [0.0000000e+00, 0.0000000e+00, 0.0000000e+00],
#       [0.0000000e+00, 0.0000000e+00, 0.0000000e+00]], dtype=float32)

detach

detach()はnumpyには無い概念です。これは自動微分機能にまつわるメソッドで、自動微分に必要な処理・メモリ利用を不要である場合に用います。

# 計算グラフから切り離されたTensorのコピーを得る。
# 計算グラフは自動微分で利用される。自動微分は後述。
# numpy()メソッドなどは計算グラフから切り離されてなければ呼べない。
X_detached = X.detach()

clone

これは計算グラフを維持したTensorのコピーを返します。基本的にTensor型は各々が自身がどのように計算されて出てきたものであるのかを履歴として持っています。従って、値が同じだけのTensorをコピーして作るのと、このメソッドで計算履歴ごとまるまる複製するのでは意味合いが異なってくることに注意が必要です。

# 計算グラフを維持したTensorのコピーを得る。
X_cloned = X.clone()

inplace

inplace処理は、処理結果として別のメモリにTensorを作る普通の処理とは異なり、施したTensorそれ自体に作用します。 具体例を見ましょう。

# inplace処理と非inplace処理
# X_clonedの値
print(X_cloned)
# X_cloned.pow(2) はX_clonedの各要素を二乗した"新しい"Tensorを返す
X_cloned.pow(2)
# X_cloned自体は変わっていない
print(X_cloned)
# X_cloned.pow_ はinplace処理である。新しいメモリを作らないため高速。
X_cloned.pow_(2)
# X_cloned自身が書き換わっている
print(X_cloned)

# tensor([[1.1348e+29, 0.0000e+00, 0.0000e+00],
#         [0.0000e+00, 0.0000e+00, 0.0000e+00],
#         [0.0000e+00, 0.0000e+00, 0.0000e+00]])
# tensor([[1.1348e+29, 0.0000e+00, 0.0000e+00],
#         [0.0000e+00, 0.0000e+00, 0.0000e+00],
#         [0.0000e+00, 0.0000e+00, 0.0000e+00]])
# tensor([[inf, 0., 0.],
#         [0., 0., 0.],
#         [0., 0., 0.]])

t

行列でお馴染みの転置操作です。

# Zの転置を返す。Z.t_()とするとinplace処理になる。
Z = torch.randn(3, 3)
print(Z)
print(Z.t())

#tensor([[ 0.2767, -0.3677,  1.2243],
#        [ 0.2109,  0.6799,  0.2212],
#        [-0.3987, -0.5688,  0.1003]])
#tensor([[ 0.2767,  0.2109, -0.3987],
#        [-0.3677,  0.6799, -0.5688],
#        [ 1.2243,  0.2212,  0.1003]])

reshape と view

この2つのメソッドは一見見た目は一緒です。しかし、プログラマから見えないメモリでの扱いが若干異なります。

# Z.reshape(rows, cols) は各要素をメモリに連続的に並び替えた上で指定したshapeに変えたTensorを返す
Z = torch.arange(0, 5, 0.5)
Z.reshape(2, 5)

#tensor([[0.0000, 0.5000, 1.0000, 1.5000, 2.0000],
#        [2.5000, 3.0000, 3.5000, 4.0000, 4.5000]])
# Z.view(rows, cols) はメモリ配置は気にせず指定したshapeのTensorを返す。
# メモリに連続的に配置されていることを前提として動作する処理も存在するため、reshapeの方が安全。
Z.view(2, 5)

#tensor([[0.0000, 0.5000, 1.0000, 1.5000, 2.0000],
#        [2.5000, 3.0000, 3.5000, 4.0000, 4.5000]])

その他、Tensor型はかなりリッチで sin() など数学関連、論理演算関連もメソッドとして準備されているが割愛します。

自動微分

ここからが本番です。PyTorchの最も重要な概念である自動微分を紹介します。

torch.autograd.grad 関数

$$ Y = 3 X _ 1 + X _ 2 ^ 2 $$

という式を扱ってみます。下記のコードによって具体的な値を使って見てみましょう。

X1 = torch.tensor(3., requires_grad=True)
X2 = torch.tensor(2., requires_grad=True)

Y = 3*X1 + X2**2
Y

# tensor(13., grad_fn=<AddBackward0>)

X1X2requires_grad=True というフラグが渡されています(これはデフォルトではFalseになっています)。このフラグがTrueであるTensorを計算に含んでいる場合は、その計算結果であるYが計算の履歴を把握しており、微分でどのような処理をしなければならないか(Chain Ruleの適用の仕方)をgrad_fn として保持してくれるという仕組みになっています。

さて、では微分の値を求めるためにここで torch.autograd.grad(outputs, inputs) という関数を使ってみましょう。

torch.autograd.grad(Y, [X1, X2])
# (tensor(3.), tensor(4.))

これは、

$$ \left(\frac{\partial Y}{\partial {X _ 1}} _ {X _ 1 = 3}, \frac{\partial Y}{ \partial {X _ 2}} _ {X _ 2 = 2}\right) $$

を返してくれていることが分かります。今回のような単純なケースでなくとも、Chain Ruleが適用できる形式の計算でありさえすれば、自動で微分計算を行ってくれるという代物です。もっと複雑な計算を見てみましょう。

X1 = torch.tensor(2., requires_grad=True)
X2 = torch.tensor(-3., requires_grad=True)
X3 = torch.tensor(1., requires_grad=True)
Z1 = X1**2 + X2 - X3
Z2 = X2 - 3*X3
Y = Z1 + Z2 + Z1*Z2

Y
# tensor(-6., grad_fn=<AddBackward0>)

という計算に対して、同じように微分の値を求めてみます。

torch.autograd.grad(Y, [X1, X2, X3], retain_graph=True)
#(tensor(-20.), tensor(-4.), tensor(2.))

さて、ここでいきなり、retain_graph=True というフラグがあらわれました。 実はこうしておくと、自動微分計算後も計算グラフを保持してくれるのです。言い換えるとこのフラグが無い場合は計算グラフは破棄されてしまうことになります(微分の計算をして用済みになったものをメモリに残しておく意味はないからだ。もちろん、retain_graph=True の方をデフォルトにすべきだ!とか意見はあるかもしれないが、そういう仕様です)。

もちろん計算の中間で出てきた Z1 Z2 に関する微分も得られるので確認してみます。

# 上でretain_graph=True としておいたので、Yの計算グラフをそのまま使える。
# ここでは retain_graph=True としない。無駄に計算グラフ保持にメモリを使う必要はない。
torch.autograd.grad(Y, [Z1, Z2])
# (tensor(-5.), tensor(1.))

これであなたも自動微分マスターになりました。おめでとうございます。

backwardメソッド

実を言うと上記の方法で自動微分を実行するコードを書くケースは稀です。

上記の方法では Y を、どの変数で微分したいかを指定して、計算グラフを辿って微分を実行していたのでした。実は、少なくともニューラルネットワークを扱う限り、このやり方だと若干の面倒が生じてきます。

ニューラルネットワークにはパラメータが大量にあるわけですが、一方で、どのパラメータの勾配が欲しいかは、多くの場合最初から決まっています。そうであれば毎回微分計算のときにパラメータを指定するのが面倒であるということになるわけです。

そこでbackward() メソッドを利用します。

X1 = torch.tensor(2., requires_grad=True)
X2 = torch.tensor(-3., requires_grad=True)
X3 = torch.tensor(1., requires_grad=True)
Z1 = X1**2 + X2 - X3
Z2 = X2 - 3*X3
Y = Z1 + Z2 + Z1*Z2

Y.backward()

上記のY.backward()の処理では、Y の計算に関わったすべてのTensor達に関する微分が計算されています。ではその計算結果はどこに行ってしまったのでしょうか。

ここがPyTorchの肝となる部分ですが、微分の計算結果は各々のTensor.gradに格納されているのです。具体的に表示してみましょう。

print(X1.grad)
print(X2.grad)
print(X3.grad)
# 下記の中間計算で出てきた物は`grad` の取得に失敗する
print(Z1.grad)
print(Z2.grad)

#tensor(-20.)
#tensor(-4.)
#tensor(2.)
#None
#None

さて、X1, X2, X3 に関しては確かに値は torch.autograd.grad 関数を使ったときと同じです。しかし Z1, Z2 には値が入っていません。Warningの内容を読んでみると、backward() メソッドは計算の中間で得られた値に関しては微分値を持ってこれないようです(計算グラフ上の葉ノードのみを扱う仕組みになっているようだ。実際、ニューラルネットワークで勾配が必要なのは学習パラメータであり、学習パラメータは葉ノードになっていることが多い)。

したがって backward()の前に微分計算が必要であることをretain_grad()にて明示する必要があります。

X1 = torch.tensor(2., requires_grad=True)
X2 = torch.tensor(-3., requires_grad=True)
X3 = torch.tensor(1., requires_grad=True)
Z1 = X1**2 + X2 - X3
Z2 = X2 - 3*X3
Y = Z1 + Z2 + Z1*Z2

Z1.retain_grad()
Z2.retain_grad()

Y.backward()

print(X1.grad)
print(X2.grad)
print(X3.grad)
print(Z1.grad)
print(Z2.grad)

#tensor(-20.)
#tensor(-4.)
#tensor(2.)
#tensor(-5.)
#tensor(1.)

これで標準的な自動微分機能を扱えるようになりました。

単回帰モデル

ここまでの知識を用いれば学習を実行することが可能です。まずは単回帰モデルを実践してみましょう。

$$ y = ax + b $$

によって $D = {(x _ 1, y _ 1), \cdots, (x _ N, y _ N)}$ を上手に表現してやるというものです。

import matplotlib.pyplot as plt
plt.style.use("seaborn")

def toy_data(N):
    x = torch.rand(N)
    y = 3*x + 1 + torch.randn_like(x)
    return x, y

x, y = toy_data(100)
plt.plot(x, y, "o")

f:id:s0sem0y:20200508175523p:plain

以下で学習に必要な関数を準備します。勾配法を使って単回帰を実施する場合は、

$$ f(x; a, b) = ax + b $$

というモデルを使って、出力を予測し $y _ i = f(x _ i; a, b)$ と具体値を得ます。 次に教師信号である $y _ {true}$ と比較してどれだけ違うかを評価します。

$$ {\rm Loss} =\frac{1}{N} \sum _ {i=1} ^ N (y _ i - y _ {i, true}) ^ 2 $$

違いを評価したら、その違いが減少する方向(すなわち微分して勾配を求めたらその逆方向)にパラメータを更新します。

$$ {\rm param} = {\rm param} - \epsilon \frac{\partial {\rm Loss}}{\partial {\rm param}} $$

これらの手続きを関数として準備しておきましょう。微分 $\frac{\partial {\rm Loss}}{\partial{\rm param}}$ は backward メソッドを実行すると、各paramparam.grad に格納されているのでしたから実装は大変楽になります。

def model(x, a, b):
    return a*x + b

def loss(y_pred, y_true):
    return (y_pred - y_true).pow(2).mean()

def zero_grad(a, b):
    a.grad = torch.zeros(1)
    b.grad = torch.zeros(1)

def update(a, b, lr=1e-3):
    with torch.no_grad():
        a -= lr*a.grad
        b -= lr*b.grad
    return a, b

ここで zero_grad という関数はパラメータのgrad0 書き換える処理です。この処理は、backward() による微分の計算結果が、現在のgrad に対して加算されるという仕組みになっているために必要な処理となります(なぜそんな処理になっているのだろうか。例えば勾配更新を実行するまでに複数回 backward を実行する場合があったらどうだろう、過去の値が消されていても良いだろうか?パラメータが複数のパスで共有されているケースや、分散処理で勾配をいろんな場所で計算させて、最後に更新するという処理をしたりする場合は必要な仕様だというわけだ)。

# parameter に関する勾配が必要なので requires_grad=True とする
a = torch.randn(1, requires_grad=True)
b = torch.randn(1, requires_grad=True)

for i in range(10000):
    zero_grad(a, b)

    y_pred = model(x, a, b)
    loss_value = loss(y_pred, y)
    # 計算に使われた a, b のgradに値が格納される
    loss_value.backward()
    update(a, b, lr=1e-2)
    if (i+1) % 1000 == 0:
        print("{} : loss : {}".format(i+1, loss_value.item()))

早々と学習の結果を可視化してみましょう。

print("a : {},  b : {}".format(a.item(), b.item()))

with torch.no_grad():
    x_plot = torch.linspace(0, 1, 1000)
    y_pred = model(x_plot, a, b)

plt.plot(x_plot, y_pred)
plt.plot(x, y, "o")

パラメータの係数は a : 3.0475332736968994, b : 1.0257551670074463 となっていました。まあまあです(真の関数はa, b それぞれ 3.0, 1.0)。

f:id:s0sem0y:20200508180829p:plain

さて、ここまでで説明していなかった機能を使っていたので、説明します。

PyTorchでは計算履歴をTensor自体が持っているということでしたが、そのためには相応のメモリを使うこととなります。もしもTensorを計算するのだが、それは一切微分計算を行う際には関係のない計算であったとしましょう。例えばAccuracyの計算かもしれませんし、パラメータ更新の処理かもしれません(パラメータの更新は既に勾配を計算し終わったあとの話だ)。

そういった場合には下記のコンテキストを利用することで、勾配計算には不要な処理であることを明示することができます。

with torch.no_grad():
    # 計算処理

単回帰の例でもパラメータの更新と、学習後の予測計算で利用していました。例えば他にはValidationデータを準備して、学習の途中でその評価を行う場合にも利用すべきということになります(Validationデータに対する計算が勾配に含まれてしまったら大変なことだ!)。

最後に

たったこれだけのコードで単回帰が実施できたことになります。もちろん変数をもっと増やしても基本的には zero_gradupdate , model 関数を相応に書き換えるだけで良いです。例えばパラメータはリストや辞書で管理することにすれば、いずれの関数もfor なりで簡単に書く方法がありそうですね。

まさにそれを上手に管理してくれるのが nn.Module であったりするのですが、公式を読むと良いです(身も蓋も無い)。 実際公式のドキュメントは非常に優れているので、この記事を理解していれば下記の内容はおそらく大丈夫でしょう。理解が進んでいってくれるはずです。

pytorch.org