PyTorch入門 — Tensor・Autograd・DataLoaderの基礎

深層学習の研究と実装には、効率的なフレームワークが不可欠です。NumPyでのスクラッチ実装は学習に最適ですが、GPU対応、自動微分、データ読み込みの効率化など、実用的な問題に対処するには専用のフレームワークが必要です。

PyTorchは2016年にFacebook AI Research(現Meta AI)が公開した深層学習フレームワークで、研究コミュニティで最も広く使われています。その人気の理由は「Pythonらしい直感的なAPI」と「動的計算グラフ」にあります。NumPyに近い感覚でテンソル演算ができ、通常のPythonコードを書くように計算グラフが構築されるため、デバッグが容易です。

PyTorchを理解すると、以下のことが可能になります。

  • GPU上での高速な学習: CPU比で10〜100倍の速度向上
  • 自動微分: 逆伝播の手動実装が不要。任意の計算グラフの勾配を自動計算
  • 豊富な事前学習モデル: torchvisionやHugging Faceの事前学習モデルを数行で利用
  • 研究の最前線: 最新の論文の多くがPyTorchで実装を公開

本記事の内容

  • Tensor の基本操作とNumPyとの対応
  • Autograd(自動微分)の仕組み
  • nn.Moduleによるモデル設計
  • DatasetとDataLoaderによるデータ管理
  • 学習ループの設計パターン

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

Tensorの基礎

Tensorとは

PyTorchのTensorは、NumPyのndarrayに相当する多次元配列です。違いは主に2つあります。

  1. GPU上での計算が可能: .to('cuda') でGPUメモリに転送し、並列計算が可能
  2. 自動微分に対応: requires_grad=True で計算グラフを記録し、勾配を自動計算

NumPyとの対応

PyTorchのTensor APIはNumPyと非常に似ています。以下にNumPyとの対応を示します。

import numpy as np

# NumPyでのテンソル操作(PyTorchの対応を注記)
# PyTorch: import torch

# --- 生成 ---
# np.array → torch.tensor
a = np.array([1.0, 2.0, 3.0])
print(f"1次元テンソル: {a}, shape: {a.shape}, dtype: {a.dtype}")

# np.zeros → torch.zeros
b = np.zeros((3, 4))
print(f"ゼロテンソル: shape={b.shape}")

# np.ones → torch.ones
c = np.ones((2, 3))
print(f"1テンソル: shape={c.shape}")

# np.random.randn → torch.randn
d = np.random.randn(3, 4)
print(f"正規乱数テンソル: shape={d.shape}")

# np.arange → torch.arange
e = np.arange(0, 10, 2)
print(f"等間隔テンソル: {e}")

# np.linspace → torch.linspace
f = np.linspace(0, 1, 5)
print(f"等分割テンソル: {f}")

# --- 演算 ---
x = np.random.randn(3, 4)
y = np.random.randn(3, 4)

# 要素ごとの演算(+, -, *, / はそのまま対応)
z = x + y
print(f"加算: shape={z.shape}")

# 行列積: np.matmul / @ → torch.matmul / @
A = np.random.randn(3, 4)
B = np.random.randn(4, 5)
C = A @ B  # torch.matmul(A, B) / A @ B
print(f"行列積: {A.shape} @ {B.shape} = {C.shape}")

# リシェイプ: np.reshape → torch.reshape / .view
x_flat = x.reshape(-1)  # torch: x.view(-1)
print(f"flatten: {x.shape} → {x_flat.shape}")

# 転置: np.transpose / .T → torch.transpose / .T
x_t = x.T  # torch: x.T / x.transpose(0, 1)
print(f"転置: {x.shape} → {x_t.shape}")

# --- NumPyとの相互変換 ---
# torch.from_numpy(np_array) → Tensor
# tensor.numpy() → NumPy array
# ※ メモリを共有するため、一方を変更すると他方も変わる

print("\n--- NumPyテンソルのデバイス ---")
print(f"NumPyはCPUのみ。PyTorchは .to('cuda') でGPUに転送可能")

このコードはNumPyを使ってPyTorchのTensorと対応する操作を示しています。NumPyに慣れていれば、PyTorchの関数名はほぼ同じなので、移行はスムーズです。主な違いは、PyTorchでは .view() がNumPyの .reshape() に対応すること、テンソルにデバイス(CPU/GPU)の概念があること、そして requires_grad で自動微分を有効にできることです。

データ型とデバイス

PyTorchのTensorにはデータ型(dtype)とデバイス(device)の2つの属性があります。

属性 説明
dtype データの型 torch.float32(デフォルト), torch.int64
device 計算デバイス torch.device('cpu'), torch.device('cuda:0')

ニューラルネットワークの重みは通常 float32 を使いますが、最近は float16(半精度)や bfloat16 での混合精度学習(Mixed Precision Training)も一般的です。

TensorはPyTorchの基盤であり、次に説明する自動微分の仕組みもTensor上に構築されています。

Autograd(自動微分)

自動微分の仕組み

PyTorchのAutogradは、Tensorに対する全ての操作を記録し、連鎖律を自動適用して勾配を計算します。これは前の記事で学んだ誤差逆伝播法の一般化です。

import numpy as np

# PyTorchの自動微分をNumPyで概念的に再現

class AutogradTensor:
    """自動微分をシミュレートするテンソル"""

    def __init__(self, data, requires_grad=False, _children=(), _op=''):
        self.data = np.array(data, dtype=np.float64)
        self.grad = np.zeros_like(self.data)
        self.requires_grad = requires_grad
        self._backward = lambda: None
        self._prev = set(_children)
        self._op = _op

    def __add__(self, other):
        other = other if isinstance(other, AutogradTensor) else AutogradTensor(other)
        out = AutogradTensor(self.data + other.data, _children=(self, other), _op='+')

        def _backward():
            if self.requires_grad:
                self.grad += out.grad
            if other.requires_grad:
                other.grad += out.grad
        out._backward = _backward
        out.requires_grad = True
        return out

    def __mul__(self, other):
        other = other if isinstance(other, AutogradTensor) else AutogradTensor(other)
        out = AutogradTensor(self.data * other.data, _children=(self, other), _op='*')

        def _backward():
            if self.requires_grad:
                self.grad += other.data * out.grad
            if other.requires_grad:
                other.grad += self.data * out.grad
        out._backward = _backward
        out.requires_grad = True
        return out

    def __pow__(self, power):
        out = AutogradTensor(self.data ** power, _children=(self,), _op=f'**{power}')

        def _backward():
            if self.requires_grad:
                self.grad += power * self.data ** (power - 1) * out.grad
        out._backward = _backward
        out.requires_grad = True
        return out

    def backward(self):
        """トポロジカルソートで逆順に勾配を計算"""
        topo = []
        visited = set()

        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)

        build_topo(self)
        self.grad = np.ones_like(self.data)
        for v in reversed(topo):
            v._backward()

# --- デモ: f(x, y) = x^2 * y + y + 2 の勾配 ---
x = AutogradTensor(3.0, requires_grad=True)
y = AutogradTensor(4.0, requires_grad=True)

# 順伝播
z = x ** 2 * y + y + AutogradTensor(2.0)

# 逆伝播
z.backward()

print("=== 自動微分のデモ ===")
print(f"f(x, y) = x^2 * y + y + 2")
print(f"x = {x.data}, y = {y.data}")
print(f"f(3, 4) = 9*4 + 4 + 2 = {z.data}")
print(f"df/dx = 2*x*y = 2*3*4 = {x.grad} (解析解: 24)")
print(f"df/dy = x^2 + 1 = 9+1 = {y.grad} (解析解: 10)")

このコードは、PyTorchの Autograd がどのように動作するかを概念的に再現しています。各演算が「計算グラフのノード」を生成し、backward() を呼ぶとトポロジカルソート(逆順)で各ノードの _backward が実行されます。PyTorchでは torch.Tensorrequires_grad=True を設定するだけで同じ仕組みが自動的に動きます。計算結果の勾配が解析解($\partial f/\partial x = 24$, $\partial f/\partial y = 10$)と一致していることが確認できます。

勾配の計算フロー

PyTorchでの勾配計算のフローは以下の通りです。

  1. requires_grad=True のテンソルに対する操作が計算グラフに記録される
  2. スカラーの損失値に対して .backward() を呼ぶ
  3. 計算グラフを逆順にたどり、連鎖律で各パラメータの勾配を計算
  4. 勾配は各テンソルの .grad 属性に蓄積される

注意点として、.grad.backward() を呼ぶたびに加算されます。学習ループでは毎回 optimizer.zero_grad() で勾配をリセットする必要があります。

自動微分の仕組みを理解したところで、次にPyTorchでニューラルネットワークを構築する標準的な方法を見ていきましょう。

nn.Moduleによるモデル設計

nn.Moduleの基本

PyTorchでは torch.nn.Module を継承してモデルを定義します。NumPyでの概念的な実装を示します。

import numpy as np

# PyTorchの nn.Module に対応する概念的な実装

class Module:
    """PyTorchの nn.Module を模擬"""

    def __init__(self):
        self._parameters = {}
        self._modules = {}

    def __call__(self, *args, **kwargs):
        return self.forward(*args, **kwargs)

    def forward(self, x):
        raise NotImplementedError

    def parameters(self):
        """全パラメータを返す"""
        params = list(self._parameters.values())
        for module in self._modules.values():
            params.extend(module.parameters())
        return params


class Linear(Module):
    """全結合層: torch.nn.Linear に対応"""

    def __init__(self, in_features, out_features):
        super().__init__()
        # He初期化
        self.weight = np.random.randn(out_features, in_features) * np.sqrt(2.0 / in_features)
        self.bias = np.zeros(out_features)
        self._parameters = {'weight': self.weight, 'bias': self.bias}

    def forward(self, x):
        # x: (batch, in_features) → (batch, out_features)
        return x @ self.weight.T + self.bias


class ReLU(Module):
    """ReLU活性化関数: torch.nn.ReLU に対応"""

    def forward(self, x):
        return np.maximum(0, x)


class Sequential(Module):
    """層の連結: torch.nn.Sequential に対応"""

    def __init__(self, *layers):
        super().__init__()
        self.layers = layers
        for i, layer in enumerate(layers):
            self._modules[str(i)] = layer

    def forward(self, x):
        for layer in self.layers:
            x = layer(x)
        return x


# --- モデル定義の例 ---
# PyTorchでは:
# model = nn.Sequential(
#     nn.Linear(2, 64),
#     nn.ReLU(),
#     nn.Linear(64, 32),
#     nn.ReLU(),
#     nn.Linear(32, 1),
# )

model = Sequential(
    Linear(2, 64),
    ReLU(),
    Linear(64, 32),
    ReLU(),
    Linear(32, 1),
)

# テスト
x_test = np.random.randn(4, 2)  # バッチサイズ4, 2次元入力
output = model(x_test)
print(f"入力: {x_test.shape}")
print(f"出力: {output.shape}")
print(f"パラメータ数: {sum(p.size for p in model.parameters())}")

このコードでは、PyTorchの nn.Modulenn.Linearnn.ReLUnn.Sequential をNumPyで概念的に再現しています。Sequential にモジュールを渡すだけでネットワークが構築され、model(x) で推論が実行されます。PyTorchでは、これに加えて自動微分、GPU転送、パラメータ管理が透過的に行われます。

カスタムモデルの定義

nn.Sequential では対応できない複雑なアーキテクチャ(残差接続など)の場合、forward メソッドを直接定義します。

import numpy as np

# カスタムモデルの例(ResNet風の残差ブロック)
# PyTorchでは:
# class ResBlock(nn.Module):
#     def __init__(self, dim):
#         super().__init__()
#         self.fc1 = nn.Linear(dim, dim)
#         self.fc2 = nn.Linear(dim, dim)
#
#     def forward(self, x):
#         residual = x
#         x = torch.relu(self.fc1(x))
#         x = self.fc2(x)
#         return torch.relu(x + residual)

# NumPyでの概念的な実装
class ResBlock:
    def __init__(self, dim):
        self.W1 = np.random.randn(dim, dim) * np.sqrt(2.0 / dim)
        self.b1 = np.zeros(dim)
        self.W2 = np.random.randn(dim, dim) * np.sqrt(2.0 / dim)
        self.b2 = np.zeros(dim)

    def forward(self, x):
        residual = x
        h = np.maximum(0, x @ self.W1.T + self.b1)  # ReLU(FC1)
        h = h @ self.W2.T + self.b2                  # FC2
        return np.maximum(0, h + residual)             # ReLU(h + residual)

# テスト
block = ResBlock(32)
x = np.random.randn(4, 32)
out = block.forward(x)
print(f"ResBlock: 入力 {x.shape} → 出力 {out.shape}")
print(f"残差接続により入出力のshapeが同じ")

この例はPyTorchの nn.Module で残差ブロックを実装する際のパターンを示しています。forward メソッドに計算の流れを直接書くことで、if文やfor文、条件分岐を含む任意の計算グラフを記述できます。これがPyTorchの「動的計算グラフ」の利点です。

DatasetとDataLoader

効率的なデータ管理

大規模なデータセットでは、全データをメモリに載せるのは現実的でありません。PyTorchの DatasetDataLoader は、データの遅延読み込みとミニバッチ生成を効率的に行う仕組みです。

import numpy as np

# PyTorchの Dataset / DataLoader を概念的に再現

class Dataset:
    """torch.utils.data.Dataset に対応"""

    def __len__(self):
        raise NotImplementedError

    def __getitem__(self, idx):
        raise NotImplementedError


class MoonsDataset(Dataset):
    """月型データセット"""

    def __init__(self, n_samples=1000, noise=0.2):
        from sklearn.datasets import make_moons
        self.X, self.y = make_moons(n_samples=n_samples,
                                     noise=noise, random_state=42)
        self.X = self.X.astype(np.float32)
        self.y = self.y.astype(np.float32)

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]


class DataLoader:
    """torch.utils.data.DataLoader に対応"""

    def __init__(self, dataset, batch_size=32, shuffle=True):
        self.dataset = dataset
        self.batch_size = batch_size
        self.shuffle = shuffle

    def __iter__(self):
        indices = np.arange(len(self.dataset))
        if self.shuffle:
            np.random.shuffle(indices)

        for start in range(0, len(indices), self.batch_size):
            batch_idx = indices[start:start + self.batch_size]
            batch_x = np.array([self.dataset[i][0] for i in batch_idx])
            batch_y = np.array([self.dataset[i][1] for i in batch_idx])
            yield batch_x, batch_y

    def __len__(self):
        return (len(self.dataset) + self.batch_size - 1) // self.batch_size


# --- 使用例 ---
dataset = MoonsDataset(n_samples=500, noise=0.2)
dataloader = DataLoader(dataset, batch_size=64, shuffle=True)

print(f"データセットサイズ: {len(dataset)}")
print(f"バッチ数: {len(dataloader)}")

# 1エポック分のデータを走査
for batch_idx, (X_batch, y_batch) in enumerate(dataloader):
    if batch_idx == 0:
        print(f"バッチ0: X.shape={X_batch.shape}, y.shape={y_batch.shape}")
    if batch_idx == len(dataloader) - 1:
        print(f"最終バッチ: X.shape={X_batch.shape}, y.shape={y_batch.shape}")

このコードは、PyTorchの DatasetDataLoader の仕組みを概念的に再現しています。Dataset はデータへのアクセスインターフェースを定義し、DataLoader はシャッフルとバッチ分割を自動化します。PyTorchの実装では、これに加えてマルチプロセスでのデータ読み込み(num_workers)やピンメモリ(pin_memory)による転送高速化が利用できます。

DataLoaderの仕組みを理解したところで、これまでの要素を組み合わせた実践的な学習ループを構築しましょう。

学習ループの設計パターン

標準的な学習ループ

PyTorchの学習ループは以下のパターンに従います。NumPyでの概念的な実装を示します。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

np.random.seed(42)

# --- 簡易的なフレームワーク ---

def sigmoid(z):
    return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

class SimpleMLP:
    """学習ループのデモ用MLP"""

    def __init__(self, dims):
        self.Ws = []
        self.bs = []
        for i in range(len(dims) - 1):
            self.Ws.append(np.random.randn(dims[i+1], dims[i])
                           * np.sqrt(2.0 / dims[i]))
            self.bs.append(np.zeros((dims[i+1], 1)))

    def forward(self, X):
        """X: (features, batch)"""
        self.hs = [X]
        self.zs = []
        h = X
        for i in range(len(self.Ws)):
            z = self.Ws[i] @ h + self.bs[i]
            self.zs.append(z)
            if i < len(self.Ws) - 1:
                h = np.maximum(0, z)
            else:
                h = sigmoid(z)
            self.hs.append(h)
        return h

    def backward(self, t, lr):
        m = t.shape[1]
        delta = self.hs[-1] - t
        for i in range(len(self.Ws) - 1, -1, -1):
            dW = (1/m) * delta @ self.hs[i].T
            db = (1/m) * np.sum(delta, axis=1, keepdims=True)
            if i > 0:
                delta = (self.Ws[i].T @ delta) * (self.zs[i-1] > 0).astype(float)
            self.Ws[i] -= lr * dW
            self.bs[i] -= lr * db

    def compute_loss(self, y, t):
        eps = 1e-8
        return -np.mean(t * np.log(y + eps) + (1-t) * np.log(1-y + eps))

# データ準備
X_data, y_data = make_moons(n_samples=1000, noise=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X_data, y_data, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

# --- 学習ループ(PyTorch風のパターン) ---
model = SimpleMLP([2, 64, 32, 1])
lr = 0.5
n_epochs = 100
batch_size = 64

train_losses = []
test_losses = []
train_accs = []
test_accs = []

for epoch in range(n_epochs):
    # シャッフル
    indices = np.random.permutation(len(X_train))
    epoch_loss = 0
    n_batches = 0

    # ミニバッチ学習
    for start in range(0, len(X_train), batch_size):
        batch_idx = indices[start:start + batch_size]
        X_batch = X_train[batch_idx].T
        y_batch = y_train[batch_idx].reshape(1, -1)

        # PyTorch風のステップ:
        # 1. optimizer.zero_grad()  → (勾配リセット、ここでは不要)
        # 2. output = model(input)  → 順伝播
        # 3. loss = criterion(output, target)  → 損失計算
        # 4. loss.backward()  → 逆伝播
        # 5. optimizer.step()  → パラメータ更新

        y_pred = model.forward(X_batch)
        loss = model.compute_loss(y_pred, y_batch)
        model.backward(y_batch, lr)

        epoch_loss += loss
        n_batches += 1

    # エポック終了時の評価
    train_loss = epoch_loss / n_batches

    # テスト評価
    y_test_pred = model.forward(X_test.T)
    test_loss = model.compute_loss(y_test_pred, y_test.reshape(1, -1))

    train_acc = np.mean(
        (model.forward(X_train.T) > 0.5).astype(int)
        == y_train.reshape(1, -1))
    test_acc = np.mean(
        (y_test_pred > 0.5).astype(int) == y_test.reshape(1, -1))

    train_losses.append(train_loss)
    test_losses.append(test_loss)
    train_accs.append(train_acc)
    test_accs.append(test_acc)

    if (epoch + 1) % 20 == 0:
        print(f"Epoch {epoch+1:3d}: "
              f"train_loss={train_loss:.4f}, test_loss={test_loss:.4f}, "
              f"train_acc={train_acc:.1%}, test_acc={test_acc:.1%}")

# --- 学習曲線の可視化 ---
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

ax = axes[0]
ax.plot(train_losses, linewidth=2, label="Train Loss", color="tab:blue")
ax.plot(test_losses, linewidth=2, label="Test Loss", color="tab:orange")
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel("Loss", fontsize=12)
ax.set_title("Training & Test Loss", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)

ax = axes[1]
ax.plot(train_accs, linewidth=2, label="Train Accuracy", color="tab:blue")
ax.plot(test_accs, linewidth=2, label="Test Accuracy", color="tab:orange")
ax.set_xlabel("Epoch", fontsize=12)
ax.set_ylabel("Accuracy", fontsize=12)
ax.set_title("Training & Test Accuracy", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.set_ylim(0.5, 1.05)

plt.tight_layout()
plt.savefig("pytorch_training_loop.png", dpi=150, bbox_inches="tight")
plt.show()

この学習曲線から、標準的な学習ループの振る舞いが確認できます。

  1. 損失曲線(左図): 訓練損失(青)とテスト損失(オレンジ)が共に減少しており、モデルが正しく学習していることがわかります。両者の差が小さいことから、過学習は起きていません。ミニバッチ学習のため、訓練損失にはわずかな振動が見られます

  2. 精度曲線(右図): 訓練精度とテスト精度が共に上昇し、高い精度に収束しています。テスト精度が訓練精度にほぼ追従していることから、汎化性能も良好です

PyTorchの実践的なTips

よくあるパターン

パターン 説明
model.train() / model.eval() Dropout, BatchNormの振る舞いを切り替え
with torch.no_grad(): 推論時に計算グラフの構築を無効化(メモリ節約)
torch.save() / torch.load() モデルの保存と読み込み
lr_scheduler 学習率のスケジューリング
torch.cuda.amp 混合精度学習でGPUメモリを節約

デバッグのポイント

  1. 次元のミスマッチ: print(tensor.shape) を多用してshapeを確認する
  2. NaN / Inf: 学習率が大きすぎるか、損失関数にlog(0)が含まれていないか確認
  3. 勾配の消失/爆発: torch.nn.utils.clip_grad_norm_ で勾配クリッピング
  4. model.eval()の忘れ: 推論時にDropoutやBatchNormが学習モードのままだとバグる

まとめ

本記事では、PyTorchの基礎をTensorから学習ループまで体系的に解説しました。

  • TensorはNumPyのndarrayに対応し、GPU計算と自動微分をサポートする
  • Autogradは計算グラフを動的に構築し、.backward()で全パラメータの勾配を自動計算する
  • nn.Moduleはモデルの標準的な定義方法であり、forwardメソッドに計算の流れを記述する
  • Dataset/DataLoaderはデータの遅延読み込みとミニバッチ生成を効率化する
  • 学習ループはzero_grad → forward → loss → backward → stepの5ステップパターン

次のステップとして、以下の記事も参考にしてください。