nykergoto’s blog

機械学習とpythonをメインに、雑多な内容をとりとめなく扱うブログです。

Pytorch ことはじめ

0.40 の update もできて話題の pytorch を触り始めたのでその時のメモ。

出てくるコードはすべて以下のリポジトリ/notebooks/start で見ることができます。

github.com

参考資料

インストール

公式を見ると conda を用いてインストールするように言われるのでおとなしく従います。

conda install -y pytorch torchvision cuda91 -c python

ubuntu16.04 + pyenv + miniconda3-4.1.11 の環境ではすんなりとインストールできました。
docker つかって環境分離できたほうがよいよなあと思い公式の DockerImage をビルドしようとしたもののどうにも上手くビルドができなかった。(どうやら pytorch 公式が Docker まわり適当に放置しているみたい?) conda でインストールするバージョンの Dockerfile を作ってみた のでこちらからイメージを作成しても良いかなと思います。

Pytorch のきほん

pytorch で扱うベクトルは torch.Tensor クラスのものを用います。 このクラスには numpy っぽいメソッドがぞろぞろと定義されていて、基本的に numpy と同じ操作ができると思ってOKです。 スライス操作とかもできるので numpy に慣れている人からするととてもありがたい。

>>> x = torch.randn(2, 3)
>>> x.__class__
<class 'torch.Tensor'>
>>> x.shape
torch.Size([2, 3])

>>> x.reshape(-1)
tensor([ 1.5101, -0.4492, -0.1451,  0.8803,  0.0047, -0.0675])

>>> x[None, :]
tensor([[[ 1.5101, -0.4492, -0.1451],
         [ 0.8803,  0.0047, -0.0675]]])

# 行列積の計算は tensor.mm
>>> y = torch.randn(2, 3)
>>> y.mm(x)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: size mismatch, m1: [2 x 3], m2: [2 x 3] at /opt/conda/conda-bld/pytorch_1524585239153/work/aten/src/TH/generic/THTensorMath.c:2033
>>> y.mm(x.t())
tensor([[ 0.0598,  0.1263],
        [ 0.6540, -0.0636]])

ただ負の数のステップ幅のスライスには対応していないみたい。(逆順に取ってきたい時の x[::-1] が使えない)

>>> x[::-1, :]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: negative step not yet supported

もしやりたい時には

  1. 一回 numpy array に変換してからスライス操作をかけて
  2. そのオブジェクトをコピー
  3. コピーされた numpy array を torch.Tensor に変換

というステップを取る必要がある。 ちょっとめんどくさい。 コピーをしないと

>>> torch.from_numpy(x.numpy()[::-1, :].copy())
tensor([[ 0.8803,  0.0047, -0.0675],
        [ 1.5101, -0.4492, -0.1451]])

このステップ2のコピーをするところをサボると負の値でのスライスを検知してエラーになってしまうので注意。(将来のリリースで対応するよ!っていうメッセージがでる)

>>> torch.from_numpy(x.numpy()[::-1, :])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: some of the strides of a given numpy array are negative. This is currently not supported, but will be added in future releases.

ニューラルネットワークを作ってみる

では実際にニューラルネットワークを構築してみましょう。 想定するニューラルネットワークは 100 次元の実数値 $x \in \mathbb{R}^{100}$ を入力にとり、1次元の実数予測値 $\hat{y} \in \mathbb{R}$ を返すようなものです。

$$ \begin{align} z = W_1^T x \\ h = {\rm relu}(z) \\ \hat{y} = W_2^T h \end{align} $$

ここで $W_1 \in \mathbb{R}^{100 \times 1000}, W_2 \in \mathbb{R}^{10 \times 100}$ はそれぞれネットワークの重みを指します。

最初から pytorch の便利モジュールを使うとあれなので

  1. 手計算で更新
  2. Autograd をつかって更新
  3. torch.nn.Module & optim つかって更新

というふうにステップを経て実装を強化して行きます。

手計算で更新

行列計算ができるので、重みに対する backward をチェーンルールをつかって手計算で出して、その更新ルールにしたがって重みをアップデートする、というパワープレイから。 今回のモデルに対する勾配はロス関数を RMSE として

$$ f(y, \hat{y}) = \| y - \hat{y} \|^2 $$

とすると重みに対する勾配は以下のようになります。

$$ \begin{align} \frac{\partial f}{\partial W_2} &= - 2 (y - \hat{y}) h \\ \frac{\partial f}{\partial W_1} &= (- 2 (y - \hat{y}) W_1 {\rm diag}(H_0(h)) x )^T \\ &= -2 x^T (y - \hat{y}) W_1 {\rm diag}(H_0(h)) \end{align} $$

なのでこれにしたがって更新するように自分で書けばOK。*1

dtype = torch.float
use_gpu = False

if use_gpu:
    device = torch.device("cuda:0")
else:
    device = torch.device("cpu")
variable_types = {
    "device": device,
    "dtype": dtype
}

# ネットワーク形状の定義
batch_size = 100
input_dim = 100
hidden_dim = 1000
outpu_dim = 10

x = torch.randn(batch_size, input_dim, **variable_types)
y = torch.randn(batch_size, outpu_dim, **variable_types)

# 重みの初期値はランダムに設定
w1 = torch.randn(input_dim, hidden_dim, **variable_types)
w2 = torch.randn(hidden_dim, outpu_dim, **variable_types)

epochs = 1000
losses = []
for i in range(epochs):
    z = x.mm(w1)

    # relu activetion
    a = z.clamp(min=0)
    pred = a.mm(w2)

    loss = ((pred - y) ** 2.).sum()
    losses.append(loss.item())

    grad_pred = 2 * (pred - y)
    grad_w2 = a.t().mm(grad_pred)
    grad_a = grad_pred.mm(w2.t())
    grad_a[z < 0] = 0
    grad_w1 = x.t().mm(grad_a)

    w1 -= lr * grad_w1 
    w2 -= lr * grad_w2
    
    if i % 200 == 0:
        lr *= .5

Autograd を使う

先の例では自分で勾配を計算していました。 pytorch には計算グラフから自動的に勾配を計算する機能 (autograd) が実装されています。 この機能を使うと予測値と正解ラベルとのロスを計算する順伝搬だけ計算すれば勾配の部分 (backward) を自動的に算出してくれます。

使うためにはテンソルの定義部分で require_grad=True にすればOKです。

autograd をつかった勾配法の実装は以下のようになりました。先程のように具体的に勾配を計算する必要が無いためシンプルに記述できています。

variable_types = {
    "device": device,
    "dtype": dtype
}

variable_types["requires_grad"] = True

w1 = torch.randn(input_dim, hidden_dim, **variable_types)
w2 = torch.randn(hidden_dim, outpu_dim, **variable_types)

lr = 1e-6

losses = []
for epoch in range(epochs):
    
    pred = x.mm(w1).clamp(min=0).mm(w2)
    loss = (y - pred).pow(2).sum()
    
    # loss に対して backward を呼び出すと grad が自動計算される
    loss.backward()
    losses.append(loss.item())

    # 以下で重みを update するがこの時の変更は autograd に追跡されたくない。
    # `with torch.no_grad()` の部分では追跡を行わないようになる。
    # 他の方法としては tensor の値を直接変更するという方法がある
    # ex). w1.data -= lr* w1.grad.data
    # しかしこの方法だと履歴を追跡できなくなる
    with torch.no_grad():
        w1 -= lr * w1.grad
        w2 -= lr * w2.grad
        
        # 破壊的に勾配の値を 0 にする
        # 破壊的な変更は suffix に _ がつく
        w1.grad.zero_()
        w2.grad.zero_()

torch.nn.Module & optim をつかって更新

最後に torch.nn.Module と optim を使って重みの更新部分を隠匿してみましょう。

torch.nn.Module の使い方はこのクラスを継承して super().__init__() を呼び出すだけです。 以下は今回考えているニューラルネットワークの定義を Module を使って定義したものです。

class SimpleNNModel(torch.nn.Module):
    """
    二層のニューラルネットワークモデル"""
    
    def __init__(self, input_dim=100, output_dim=10, hidden_dim=1000):
        super().__init__()
        
        self.dense1 = torch.nn.Linear(input_dim, hidden_dim, bias=False)
        self.dense2 = torch.nn.Linear(hidden_dim, outpu_dim, bias=False)
        
    def forward(self, x):
        """
        順伝搬の計算を行って予測値を返す"""
        
        h = self.dense1(x)
        
        # equal to relu activation
        h = h.clamp(min=0)
        
        pred = self.dense2(h)
        return pred

継承したクラスでは Module に定義されている様々なメソッドを利用可能です。例えば toインスタンス内のデータすべてを cpu or gpu のメモリに展開する手助けをします。

model = SimpleNNModel()
model = model.to(device)

あとはパラメータを列挙したり, プロパティ内の Module を列挙したりもできます

for c in model.children():
    print(c)

# Linear(in_features=100, out_features=1000, bias=False)
# Linear(in_features=1000, out_features=10, bias=False)

for p in model.parameters():
    print(p)

# Parameter containing:
# tensor(1.00000e-02 *
#        [[ 7.6395, -6.7308,  9.9475,  ...,  1.5201, -3.2150, -0.1836],
#         [-8.0257,  9.9207,  2.4222,  ...,  4.2445,  8.2807,  7.5381],
#         [ 3.3367,  0.5269, -0.0468,  ...,  2.7539,  9.4210,  7.7625],
#         ...,
#         [ 6.5938, -3.1973, -0.3842,  ..., -3.0842,  6.5831,  4.6253],
#         [ 0.4635, -2.5175, -9.1168,  ...,  5.4131, -5.4361, -6.6949],
#         [ 2.5444,  9.0936, -3.0305,  ...,  6.1671,  4.1751, -3.2943]])
# Parameter containing:
# tensor(1.00000e-02 *
#        [[-3.0642,  3.1571,  1.1157,  ...,  2.1682,  2.4327, -1.7542],
#         [ 0.6330, -3.1313, -1.7368,  ...,  2.4449, -2.3944,  2.3621],
#         [-0.6492,  1.6452,  0.9604,  ..., -2.4719, -0.7905,  2.3031],
#         ...,
#         [-1.0288, -3.1262, -1.0839,  ..., -0.7796, -2.2369,  2.7909],
#         [ 0.0137, -2.7335,  2.7928,  ..., -1.3900,  0.6610,  2.2154],
#         [-3.1476, -2.1980, -1.7415,  ..., -2.2474, -2.0570, -2.9524]])

optim モジュールには optimizer が多数定義されています。 基本的に第一引数にその optimizer で更新したいパラメータの配列をわたし、その他の optimizer 自体のパラメータをそれに続いて渡す、というふうにインスタンスを生成します。

Module には parameters メソッドでパラメータ列挙ができるので、組み合わせて使うと簡単に optimizer に更新パラメータを知らせることができます。

# 勾配の初期化, 重みの update などの方法(アルゴリズム) が torch.optima にいろいろ定義されている
# 第一引数にこの optimizer で update したいパラメータの配列を渡す. 
# 今回は単純な stochastic gradient descent を使う
optimizer = torch.optim.SGD(model.parameters(), lr=1e-5, momentum=.8, weight_decay=1e-8, nesterov=True)

optim には zero_grad step メソッドが定義されています。 これらは、登録されたパラメータに対してそれぞれ勾配の初期化と更新を行ってくれるので、いちいち列挙しなくてよくてらくちんです。

ロス関数に関しても RMSE や CrossEntropy のような普通のロスであれば torch.nn 配下に定義されているのでそれを用いるとらくちん

# `criterion`: 予測値と正解ラベルとの差分を計算するオブジェクト
# `size_averaging` を `False` にすると, 与えられたバッチ n それぞれの rmse を合計した loss を返す。
# デフォルトでは `True` (すなわちバッチそれぞれの値を配列として返す)
criterion = torch.nn.MSELoss(size_average=False)

これら全部入りで学習スクリプトを書くと以下のようになります。

losses_sgd = []
for epoch in range(epochs):
    pred = model.forward(x)
    loss = criterion(pred, y)
    losses_sgd.append(loss.item())

    # 登録されたパラメータの勾配を 0 にする
    optimizer.zero_grad()
    loss.backward()

    # 登録されたパラメータを grad を使って更新する
    optimizer.step()

ちなみに autograd のときと module を使った時のロスの値をプロットすると以下のようなグラフになります。

圧倒的に Module がよいように見えますがこれは Module で定義されるパラメータの初期値が良い感じに設定されているからです。*2ニューラルネットワークの初期値はとても大事。)

f:id:dette:20180429072452p:plain

plt.figure(figsize=(8, 6))
plt.plot(losses, label="naive update")
plt.plot(losses_sgd, label="SGD with Nesterov and momentum")
plt.legend(loc=1)
plt.xlabel("epochs")
plt.ylabel("loss (log scale)")
plt.yscale("log")
plt.savefig("loss_transition.png", dpi=150)

*1:$H_0$ はヘヴィサイドの階段関数を指します

*2:重みのパラメータ数の平方根の逆数を上下限に持つ一様分布からサンプルされるようになっています。 http://pytorch.org/docs/master/_modules/torch/nn/modules/linear.html#Linear