Open12

Pytorch Tutorial 実況プレイ1: Quickstart

Josh NobusJosh Nobus

Pytorch Tutorial 実況プレイ

TensorFlow は基本を押さえたと思うので Pytorch も始める(会社では Pytorch 派が多いので)。

個人のメモとみなされてコミュニティガイドライン違反とみなされたのか、露骨に TensorFlow の記事の反応が悪いので Pytorch はスクラップのほうでひっそりとやろうと思う。

Josh NobusJosh Nobus

はじめに

本記事で使用するコードのほとんどは公式チュートリアルからのコピペである。気になった部分のみ挙動をチェックする目的で自分でコードを書く。

チュートリアルを流し読みした感じでは Keras も Pytorch もインターフェースはかなり似通っていて、細部の挙動は異なるのかもしれないが大部分の知識に関しては流用できそうである。

公式チュートリアル

https://pytorch.org/tutorials/beginner/basics/intro.html

日本語版

https://yutaroogawa.github.io/pytorch_tutorials_jp/

日本語版は既に Colab になってしまっていて自分で進めている感がない[1]ので困ったときだけ参照するかもしれない。

脚注
  1. 長年プログラミングしてるときのクセで、コピペする分にはかなり細部まで注意を払うのだが、動く notebook になっているとどうも流し読みしてしまう。 ↩︎

Josh NobusJosh Nobus

データセットのロード

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
# Download training data from open datasets.
training_data = datasets.FashionMNIST(
    root="data",
    train=True,
    download=True,
    transform=ToTensor(),
)

# Download test data from open datasets.
test_data = datasets.FashionMNIST(
    root="data",
    train=False,
    download=True,
    transform=ToTensor(),
)

Q. train=True/False で訓練データとテストデータの切り替えになる?
A. なる。

If True, creates dataset from train-images-idx3-ubyte, otherwise from t10k-images-idx3-ubyte.

train-images-idx3-ubyte は訓練用、t10k-images-idx3-ubyte は検証用のデータである。読み込み先を切り替えているので FashionMNIST の親クラスが勝手に訓練データと検証データに分割してくれるという便利機能があるわけではなさそう。

batch_size = 64

# Create data loaders.
train_dataloader = DataLoader(training_data, batch_size=batch_size)
test_dataloader = DataLoader(test_data, batch_size=batch_size)

for X, y in test_dataloader:
    print(f"Shape of X [N, C, H, W]: {X.shape}")
    print(f"Shape of y: {y.shape} {y.dtype}")
    break

DataLoader に直接 training_data(型は FashionMNIST) をそのまま与えられるということはイテラブルオブジェクトをラップしてくれるのだろうか。ドキュメントを読むと第一引数の型は Dataset である。

dataset (Dataset) – dataset from which to load the data.

Dataset は抽象クラスで、__getitem__()__len__() を実装していればよいとのこと(ドキュメント)。Iterable というより Mapping に近そう。あとで DataLoader のチュートリアルがあるからそのとき詳しく確認する。

An abstract class representing a Dataset.

All datasets that represent a map from keys to data samples should subclass it. All subclasses should overwrite __getitem__(), supporting fetching a data sample for a given key. Subclasses could also optionally overwrite __len__(), which is expected to return the size of the dataset by many Sampler implementations and the default options of DataLoader.

Josh NobusJosh Nobus

宿題

  • Dataset 抽象クラスのサブクラスは __getitem__()__len__() をどう実装すべきか?
Josh NobusJosh Nobus

ネットワークの定義

# Get cpu, gpu or mps device for training.
device = (
    "cuda"
    if torch.cuda.is_available()
    else "mps"
    if torch.backends.mps.is_available()
    else "cpu"
)
print(f"Using {device} device")

cuda はともかく mps は初めて見た。調べてみると Macbook の M1 チップに搭載されている GPU アクセラレータである Metal Performance Shaders のことらしい。Pytorch 2.0.0 から使用可能になったようだ。

https://zenn.dev/hidetoshi/articles/20220731_pytorch-m1-macbook-gpu

次がモデルの定義。

# Define model
class NeuralNetwork(nn.Module):
    def __init__(self):
        super().__init__()
        self.flatten = nn.Flatten()
        self.linear_relu_stack = nn.Sequential(
            nn.Linear(28*28, 512),
            nn.ReLU(),
            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Linear(512, 10)
        )

    def forward(self, x):
        x = self.flatten(x)
        logits = self.linear_relu_stack(x)
        return logits

model = NeuralNetwork().to(device)
print(model)

Keras のサブクラス化によく似ている。Keras では __setattr__() 特殊メソッドに細工してモデルに追加されたレイヤーを追跡していたが、Pytorch ではどうなっているのだろう。これも要確認だ。

私は logits という変数名を見て真っ先にロジット関数を思い浮かべたので「この定義式のどこにロジット関数が?」と混乱したのだが、どうやら「これから確率に変換しようとしている出力層のニューロンの出力値」を logits と呼ぶことが多いらしい。

https://stealthinu.hatenadiary.jp/entry/2019/06/17/151602

Josh NobusJosh Nobus

宿題

  • nn.Module は追加されたレイヤーを __setattr__() で追跡しているか?
Josh NobusJosh Nobus

モデルの最適化

loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=1e-3)

ロスとオプティマイザの定義は普通。

def train(dataloader, model, loss_fn, optimizer):
    size = len(dataloader.dataset)
    model.train()
    for batch, (X, y) in enumerate(dataloader):
        X, y = X.to(device), y.to(device)

        # Compute prediction error
        pred = model(X)
        loss = loss_fn(pred, y)

        # Backpropagation
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if batch % 100 == 0:
            loss, current = loss.item(), (batch + 1) * len(X)
            print(f"loss: {loss:>7f}  [{current:>5d}/{size:>5d}]")

model.train() はモデルを訓練するわけではなく、モデルを訓練モードに設定するとのこと。モデルの中には訓練時と評価時に挙動が異なるレイヤ(バッチ正規化やドロップアウト)があるので、その挙動を切り替えるための設定のようである。

その先がよく分からん。pred の出力値ってまだクラスラベルに変換してない実数値だと思うのだが、そのまま CrossEntropyLoss に与えてしまってよいのか?ドキュメントを読むと以下のような記述がある。

The input is expected to contain the unnormalized logits for each class (which do not need to be positive or sum to 1, in general).

正規化されていない logits(正でなくてもよいし和が 1 でなくてもよい)でよい、と書いてあるのでまぁいいのか。数式見ても入力された値は \exp の中に入れられるように変換されているので問題なさそう。

optimizer.zero_grad() は保存されている勾配を 0 に初期化するらしい。loss.backward() を呼び出す度に勾配が計算されるわけだが、複数のミニバッチに対して勾配を計算するときなどに便利なように、勾配は既存の勾配に足し込まれるような挙動になっているらしい。つまり 0 初期化しないと前のステップの勾配に現在のステップの勾配が足し込まれてしまう。

https://zenn.dev/kutabarenn/articles/b18b60178c1f15

普通に zero_grad() を書き忘れてバグりそうなので若干怖い仕様だが、確かに目的関数が複数あるようなネットワークを定義したときなどはそれぞれの目的関数で backward() を計算するだけで勾配計算できそうなので便利といえば便利である。

む。なんで loss.backward() でモデルのパラメータの勾配が計算できるのか分からん。optimizer には model.parameters() を渡してるけど loss には pred を介してしか model の情報を渡していない。pred にモデルの情報が含まれている?

ChatGPT によると Pytorch の Tensor は他の Tensor から計算によって生成される場合にはその計算グラフが内部的に追跡されているらしい。Tensor から計算グラフの情報にアクセスできるようになっているのは TensorFlow とは異なるポイントかもしれない(TensorFlow は GradientTape で明示的に関係性を記述しており、Tensor と計算グラフは分離されていると思われる)。

そのあとで optimizer.step() で更新できるのも今度は optimizerloss の情報を明示的に受け取っているようなコードがないので若干キモい。

つまり自動微分と最適化の挙動を整理すると以下のようになる。

  1. 定義したモデルにしたがって入力から pred が計算される。pred の計算にどのパラメータが使用されたかは pred 自身が保持している。
  2. pred から loss を計算して backward() すると、pred の情報にしたがって pred の計算に使用されたパラメータの Tensor に勾配が足し込まれる(上書きではなく累積される)。
  3. optimizer は初期化時に更新対象とするパラメータを受け取っているから、step() のときにそのパラメータを見に行くと、それに結びついた勾配のところに backward() が計算してくれた勾配が入っていて、その情報を使ってパラメータを更新する。

うーん、コードに明示的な繋がりが記述されてなかったり、勾配が勝手に累積になってたりするのが TensorFlow に比べると若干キモい。だが考えてみるとそれぞれの役割がきちんと分離されていて合理的である。慣れたら TensorFlow よりも使いやすいかもしれない。

def test(dataloader, model, loss_fn):
    size = len(dataloader.dataset)
    num_batches = len(dataloader)
    model.eval()
    test_loss, correct = 0, 0
    with torch.no_grad():
        for X, y in dataloader:
            X, y = X.to(device), y.to(device)
            pred = model(X)
            test_loss += loss_fn(pred, y).item()
            correct += (pred.argmax(1) == y).type(torch.float).sum().item()
    test_loss /= num_batches
    correct /= size
    print(f"Test Error: \n Accuracy: {(100*correct):>0.1f}%, Avg loss: {test_loss:>8f} \n")

model.eval() は評価モードへの切り替え。with torch.no_grad() も自動微分をオフにするためのハンドラだろう。それ以外は特に train と変わった点はなさそうである(評価指標の Accuracy が追加されているけど)。

epochs = 5
for t in range(epochs):
    print(f"Epoch {t+1}\n-------------------------------")
    train(train_dataloader, model, loss_fn, optimizer)
    test(test_dataloader, model, loss_fn)
print("Done!")

ここは特にコメントするようなことはない。

Josh NobusJosh Nobus

宿題

  • model.train(), model.eval(), with torch.no_grad() は裏で何をやっている?
  • 勾配が足し込まれていくという挙動から推測するに目的関数が複数あるときにそれぞれ backward() を計算すれば勾配が求まるはずだが、本当にそうなっている?
  • Tensor が他の Tensor から計算によって生成されるとき、その依存関係が自動的に計算グラフとして保存されている?
Josh NobusJosh Nobus

model.train(), model.eval(), with torch.no_grad() は裏で何をやっている?

model.train(), model.eval() は訓練モードと評価モードの切り替え。訓練時と評価時で挙動が異なるレイヤ(バッチ正規化やドロップアウトなど)にどちらのモードであるかを伝達するために用いられる。with torch.no_grad() は誤差逆伝播のために保存しておく一時パラメータなどを保存せず計算グラフも生成しないようなモードへの切り替え。

Josh NobusJosh Nobus

モデルの保存

torch.save(model.state_dict(), "model.pth")
print("Saved PyTorch Model State to model.pth")

state_dict() の中身が気になるので表示してみる。

OrderedDict([('linear_relu_stack.0.weight',
              tensor([[-0.0117, -0.0302,  0.0033,  ...,  0.0113, -0.0147, -0.0004],
                      [ 0.0117,  0.0001,  0.0348,  ..., -0.0253, -0.0052, -0.0062],
                      [-0.0307, -0.0159,  0.0056,  ...,  0.0352,  0.0273, -0.0336],
                      ...,
                      [-0.0102,  0.0016,  0.0077,  ..., -0.0098, -0.0329,  0.0160],
                      [-0.0035, -0.0181, -0.0169,  ..., -0.0136, -0.0228,  0.0274],
                      [ 0.0061,  0.0026,  0.0187,  ..., -0.0034, -0.0075,  0.0172]])),
             ('linear_relu_stack.0.bias',
              tensor([ 3.3723e-03, -2.8854e-02,  3.1539e-02,  3.4208e-02,  2.0286e-02,
                      -3.3755e-03,  2.6101e-02,  1.8685e-02,  1.7902e-02,  2.8680e-02,
              ...

名前とテンソルのペアが保存されてるっぽい。

Josh NobusJosh Nobus

使用

classes = [
    "T-shirt/top",
    "Trouser",
    "Pullover",
    "Dress",
    "Coat",
    "Sandal",
    "Shirt",
    "Sneaker",
    "Bag",
    "Ankle boot",
]

model.eval()
x, y = test_data[0][0], test_data[0][1]
with torch.no_grad():
    pred = model(x)
    predicted, actual = classes[pred[0].argmax(0)], classes[y]
    print(f'Predicted: "{predicted}", Actual: "{actual}"')

なるほど。出力を「どれだけそれっぽいかの確率」じゃなくて「最大値を取るやつがそれ」で推定してたのか。実数値で予測してた理由がわかった。

Quickstart はこれでおしまい。