こんにちは。現役エンジニアの三年坊主(@SannenBouzu)です。
エンジニア歴は6年。うち2年以上のAI開発を経験し、Web技術とAI技術をバランスよく習得してきました。
PyTorchに限りませんが、ネットの情報を見よう見まねでコードを書くと、「とりあえずコピペしたコードは動くけど、正直なんで動くか分かってないので、自分の用途に合わせて実装するのが難しい」という状況になりがちですよね。
今回は、PyTorchの基礎の理解を深めるため、PyTorchのTensorを使って、ごくごく簡単なニューラルネットワークを一から実装していきます。
公式チュートリアル What is torch.nn really? を掘り下げていきます。
この記事では torch.nn を一切使わないので、ニューラルネットワークの実装で普段なんとなく使っているtorch.nnが、背後でどのような処理をしているのか分かります。
その知識は、自然言語処理、画像処理など、幅広い分野に応用することができるでしょう。
- 必要最低限、PyTorchの基礎を理解したい方
- PyTorchの理解を深めて、自然言語処理や画像処理などに応用したい方
MNISTのデータを準備する
0から9まで、手書き数字の画像データセットとして有名な、MNISTを使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | from pathlib import Path import requests DATA_PATH = Path("data") PATH = DATA_PATH / "mnist" PATH.mkdir(parents=True, exist_ok=True) URL = "http://deeplearning.net/data/mnist/" FILENAME = "mnist.pkl.gz" if not (PATH / FILENAME).exists(): content = requests.get(URL + FILENAME).content (PATH / FILENAME).open("wb").write(content) |
pickle形式で保存されたデータを読み取ります。
1 2 3 4 5 | import pickle import gzip with gzip.open((PATH / FILENAME).as_posix(), "rb") as f: ((x_train, y_train), (x_valid, y_valid), _) = pickle.load(f, encoding="latin-1") |
28*28の画像が784次元のNumPy ndarrayに格納されているので、reshapeして可視化しましょう。
1 2 3 4 5 | from matplotlib import pyplot import numpy as np pyplot.imshow(x_train[0].reshape((28, 28)), cmap="gray") print(x_train.shape) |
NumPy ndarrayはそのままではPyTorchのモデル(今回はニューラルネットワーク)に入力できないので、mapで一括してtorch.tensorを適用し、Tensor型に変換します。
- torch.as_tensor や、(ndarrayと分かっているので) torch.from_numpy でもOK
- 今回の場合、これらの方法はTensorのコピーを行わない
1 2 3 4 5 6 7 8 9 10 | import torch x_train, y_train, x_valid, y_valid = map( torch.tensor, (x_train, y_train, x_valid, y_valid) ) n, c = x_train.shape x_train, x_train.shape, y_train.min(), y_train.max() print(x_train, y_train) print(x_train.shape) print(y_train.min(), y_train.max()) |
1 2 3 4 5 6 7 8 9 | tensor([[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]]) tensor([5, 0, 4, ..., 8, 4, 8]) torch.Size([50000, 784]) tensor(0) tensor(9) |
ニューラルネットワークをTensorで実装する(torch.nnを使わない)
入力層784次元、出力層10次元の、シンプルなニューラルネットワークを実装してみましょう。隠れ層もありません。
Xavierの初期化という方法を使って、重みに適度なバラつきを持たせて学習を安定させます。
この初期化の後に、require_grad_()
を使って勾配を記録させます。
1 2 3 4 5 | import math weights = torch.randn(784, 10) / math.sqrt(784) weights.requires_grad_() bias = torch.zeros(10, requires_grad=True) |
log softmax
入力されるTensorについて、重みとの内積(@)にバイアスを加えた結果に対して、log softmaxを計算します。
1 2 3 4 5 | def log_softmax(x): return x - x.exp().sum(-1).log().unsqueeze(-1) def model(xb): return log_softmax(xb @ weights + bias) |
サイズ64のミニバッチを、modelに入力すると、予測結果が得られます。
1 2 3 4 5 6 | bs = 64 # batch size xb = x_train[0:bs] # a mini-batch from x preds = model(xb) # predictions preds[0], preds.shape print(preds[0], preds.shape) |
10個の値は、入力した画像テンソルがそれぞれ0から9の数字である(とモデルが予測する)「確率」とみなすことができ、この値が一番大きい数字が予測結果ということになります。
今回は「9」ですが、まだ何も学習しておらず、いわば「当てずっぽう」なので、今は気にしなくて大丈夫です。
1 2 | tensor([-2.6475, -2.7023, -2.6583, -2.2863, -2.8207, -2.6221, -1.8365, -2.1001, -2.2565, -1.7622], grad_fn=<SelectBackward>) torch.Size([64, 10]) |
log softmax【段階を追って深掘り】
log_softmax 関数で、どのような処理が行われているか、段階を踏んで確認していきます。
まず、Tensorのサイズを確認。
1 2 3 4 5 6 7 | print(weights.size()) print(bias.size()) print(xb.size()) torch.Size([784, 10]) torch.Size([10]) torch.Size([64, 784]) |
具体的に、x_trainから最初の3つのデータを使ってみます。
1 2 3 4 5 6 7 | print(x_train[0:3].size()) print((x_train[0:3] @ weights + bias).size()) print(log_softmax(x_train[0:3] @ weights + bias).size()) torch.Size([3, 784]) torch.Size([3, 10]) torch.Size([3, 10]) |
log_softmax 関数の中での処理を、一つずつ適用した結果はこちら。
- sum(input, dim, keepdim=False, *, dtype=None): 指定した次元について、入力されたTensorのすべての要素の合計を返す
- unsqueeze(input, dim): 指定した位置に、「次元1」を挿入した新しいTensorを返す
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | x = x_train[0:3] @ weights + bias # torch.Size([3, 10]) print(x) # https://pytorch.org/docs/stable/generated/torch.exp.html#torch.exp print(x.exp()) # https://pytorch.org/docs/stable/generated/torch.sum.html#torch.sum # IndexError: Dimension out of range (expected to be in range of [-2, 1], but got 3) # torch.Size([3, 10]) # -2 or 0: reduce first dimension i.e. torch.Size([3, 10]) -> torch.Size([10]) # -1 or 1: reduce second dimension i.e. torch.Size([3, 10]) -> torch.Size([3]) print(x.exp().sum(-1)) # -1 # https://pytorch.org/docs/stable/generated/torch.log.html#torch.log print(x.exp().sum(-1).log()) # https://pytorch.org/docs/stable/generated/torch.unsqueeze.html#torch.unsqueeze # IndexError: Dimension out of range (expected to be in range of [-2, 1], but got 99) # -2 or 0: insert dimension 1 at first dimension i.e. torch.Size([3]) -> torch.Size([1, 3]) # -1 or 1: insert dimension 1 at second dimension i.e. torch.Size([3]) -> torch.Size([3, 1]) print(x.exp().sum(-1).log().unsqueeze(-1)) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | tensor([[ 0.1259, -0.4270, 0.5097, -0.5270, -0.1229, 0.1831, 0.2054, -0.0506, 0.1856, -0.1767], [ 0.8055, -0.7241, 0.6675, -0.2007, -0.1047, -0.1999, -0.1341, -0.2136, -0.1366, 0.0437], [ 0.5164, -0.1599, -0.2677, 0.3442, -0.3054, -0.0078, -0.3354, -0.3912, 0.1537, 0.1303]], grad_fn=<AddBackward0>) tensor([[1.1342, 0.6525, 1.6648, 0.5904, 0.8843, 1.2009, 1.2281, 0.9507, 1.2040, 0.8381], [2.2379, 0.4848, 1.9494, 0.8182, 0.9006, 0.8188, 0.8745, 0.8076, 0.8723, 1.0447], [1.6759, 0.8522, 0.7651, 1.4109, 0.7368, 0.9922, 0.7151, 0.6763, 1.1662, 1.1392]], grad_fn=<ExpBackward>) tensor([10.3478, 10.8087, 10.1298], grad_fn=<SumBackward1>) tensor([2.3368, 2.3803, 2.3155], grad_fn=<LogBackward>) tensor([[2.3368], [2.3803], [2.3155]], grad_fn=<UnsqueezeBackward0>) |
損失関数 loss function (negative log-likelihood)
1 2 3 4 | def nll(input, target): return -input[range(target.shape[0]), target].mean() loss_func = nll |
学習を行う前の、損失関数の値を確認します。
1 2 | yb = y_train[0:bs] print(loss_func(preds, yb)) |
損失関数 loss function (negative log-likelihood)【段階を追って深掘り】
クラス数C=10、データ数N=3の小さなデータを使って、この関数の処理を一つずつ追っていきます。
- 対数は取らず負の符号は取り、ベクトルの重み付き平均を計算する
- 「log」と言っているのは、入力されるpredsがlog_softmaxの結果(対数)のため
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | print('input (preds[0:3]):') print(preds[0:3]) print('target (y_train[0:3]):') print(y_train[0:3]) print() print('index to slice input:') print(range(y_train[0:3].shape[0]), y_train[0:3]) # Slice: preds[0:3][0, 5], preds[0:3][1, 0], preds[0:3][2, 4] print('[0, 5] of input: ', preds[0:3][0, 5]) print('[1, 0] of input: ', preds[0:3][1, 0]) print('[2, 4] of input: ', preds[0:3][2, 4]) print('result of slicing:', preds[0:3][range(0, 3), torch.tensor([5, 0, 4])]) print() print('negative (log) likelihood (-input[range(target.shape[0]), target].mean()):') print(-preds[0:3][range(y_train[0:3].shape[0]), y_train[0:3]].mean()) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | input (preds[0:3]): tensor([[-2.6475, -2.7023, -2.6583, -2.2863, -2.8207, -2.6221, -1.8365, -2.1001, -2.2565, -1.7622], [-2.2517, -3.1741, -2.7104, -2.2183, -2.7084, -2.2448, -1.9042, -2.1581, -2.2683, -1.9871], [-2.4092, -2.6533, -1.7135, -2.3592, -2.5421, -2.5317, -2.2215, -2.0964, -2.3956, -2.4736]], grad_fn=<SliceBackward>) target (y_train[0:3]): tensor([5, 0, 4]) index to slice input: range(0, 3) tensor([5, 0, 4]) [0, 5] of input: tensor(-2.6221, grad_fn=<SelectBackward>) [1, 0] of input: tensor(-2.2517, grad_fn=<SelectBackward>) [2, 4] of input: tensor(-2.5421, grad_fn=<SelectBackward>) result of slicing: tensor([-2.6221, -2.2517, -2.5421], grad_fn=<IndexBackward>) negative (log) likelihood (-input[range(target.shape[0]), target].mean()): tensor(2.4720, grad_fn=<NegBackward>) |
正解率 accuracy
1 2 3 | def accuracy(out, yb): preds = torch.argmax(out, dim=1) return (preds == yb).float().mean() |
学習を行う前の、正解率の値を確認します。
学習をしていないモデルは、ランダム・当てずっぽうな予測をするしかないので、当然、低いですね。
1 2 3 | print(accuracy(preds, yb)) tensor(0.0625) |
正解率 accuracy【段階を追って深掘り】
再び、クラス数C=10、データ数N=3の小さなデータを使って、この関数の処理を一つずつ追っていきます。
- 予測と正解が一致しているデータをカウント
- この例では、3つのデータのどの予測も正解と一致していないので、0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | print('out (preds[0:3]):') print(preds[0:3]) print('yb (yb[0:3]):') print(yb[0:3]) print() print('preds = torch.argmax(out, dim=1):', torch.argmax(preds[0:3], dim=1)) print('[0, 9] of out: ', preds[0:3][0, 9]) print('[1, 6] of out: ', preds[0:3][1, 6]) print('[2, 2] of out: ', preds[0:3][2, 2]) print() print('Check where prediction matches ground truth.') print('preds == yb:') print(torch.argmax(preds[0:3], dim=1) == yb[0:3]) print() print('(preds == yb).float():') print((torch.argmax(preds[0:3], dim=1) == yb[0:3]).float()) print() print('(preds == yb).float().mean():') print((torch.argmax(preds[0:3], dim=1) == yb[0:3]).float().mean()) print() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | out (preds[0:3]): tensor([[-2.6475, -2.7023, -2.6583, -2.2863, -2.8207, -2.6221, -1.8365, -2.1001, -2.2565, -1.7622], [-2.2517, -3.1741, -2.7104, -2.2183, -2.7084, -2.2448, -1.9042, -2.1581, -2.2683, -1.9871], [-2.4092, -2.6533, -1.7135, -2.3592, -2.5421, -2.5317, -2.2215, -2.0964, -2.3956, -2.4736]], grad_fn=<SliceBackward>) yb (yb[0:3]): tensor([5, 0, 4]) preds = torch.argmax(out, dim=1): tensor([9, 6, 2]) [0, 9] of out: tensor(-1.7622, grad_fn=<SelectBackward>) [1, 6] of out: tensor(-1.9042, grad_fn=<SelectBackward>) [2, 2] of out: tensor(-1.7135, grad_fn=<SelectBackward>) Check where prediction matches ground truth. preds == yb: tensor([False, False, False]) (preds == yb).float(): tensor([0., 0., 0.]) (preds == yb).float().mean(): tensor(0.) |
学習
いろいろと寄り道しましたが、いよいよモデルを学習させていきます。
- データ数
bs
のミニバッチを (xb
とyb
) 取得する - モデルに
xb
を入力して予測結果を得る - 損失を計算する
loss.backward()
でモデルのパラメータ(weights
とbias
)の勾配を更新する
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | from IPython.core.debugger import set_trace lr = 0.5 # learning rate epochs = 2 # how many epochs to train for for epoch in range(epochs): for i in range((n - 1) // bs + 1): # set_trace() start_i = i * bs end_i = start_i + bs xb = x_train[start_i:end_i] yb = y_train[start_i:end_i] pred = model(xb) loss = loss_func(pred, yb) loss.backward() with torch.no_grad(): weights -= weights.grad * lr bias -= bias.grad * lr weights.grad.zero_() bias.grad.zero_() |
学習前と比べて、損失関数の値が減少、正解率が増加したことを確認します。
1 2 3 | print(loss_func(model(xb), yb), accuracy(model(xb), yb)) tensor(0.0827, grad_fn=<NegBackward>) tensor(1.) |
まとめ:torch.nnを使わなくてもニューラルネットワークを実装できるけど面倒くさい
簡単なニューラルネットワークを、PyTorchのTensorを使って、一から実装してきました。
流れを一つ一つ追っていったので、処理の中身については理解が深まりましたが、実際にモデルを学習させるとなると、いくつか「面倒くさい」ポイントがありましたよね。
- 損失関数まわりを自分で定義した (log_softmax, negative log likelihood)
- モデルのパラメータ(
weights
とbias
)を手動で一つずつ更新した - 重みとバイアスの適用 (xb @ weights + bias) を自分で定義した
- 入力xとラベルyをそれぞれ別々にスライスしてミニバッチを生成した
- 入力が、784次元(28*28)限定
torch.nnを使えば、こうした点を改善して、もっと柔軟で、分かりやすく、実用的な形に実装できます。
別の記事にて、今回のコードをtorch.nnを活用して書き換える内容を紹介します。