はじめに
第3回では、画像ファイルを Dataset で読み込み、DataLoader でbatchにまとめ、transforms でモデルに渡せるTensorへ変換する流れを整理しました。
今回は、そのTensorを受け取るモデル側を扱います。PyTorchでニューラルネットワークを作るときの中心になるのが nn.Module です。nn.Module を理解すると、畳み込み層、プーリング層、全結合層、活性化関数をただ並べるだけでなく、パラメータがどこに登録され、どのように保存され、どのようにGPUへ移動し、train() と eval() で何が切り替わるのかまで見通せるようになります。
この記事では、画像分類を題材に、nn.Module の基本から始めて、Sequential、ModuleList、ModuleDict などのモデル容器、畳み込みの仕組み、出力サイズの計算、グループ畳み込み、depthwise convolution、dilation、転置畳み込み、プーリング、Linear、活性化関数まで順に確認します。
特に畳み込みは、CNNを読むうえで一番つまずきやすいところです。in_channels、out_channels、kernel_size、stride、padding、dilation、groups がそれぞれ何を変えるのかを、形状と式の両方から丁寧に見ていきます。
目的は、ネットワークの名前を暗記することではありません。(N, C, H, W) の画像Tensorが各層を通るたびにどう変わり、どのパラメータが学習され、最終的に分類用のlogitへ変換されるのかを、自分で追えるようになることです。
目次
- この記事で扱うこと
- 第3回から第4回へのつながり
- PyTorchでモデルを作る基本フロー
torch.nnの主な構成要素nn.Moduleの基本- パラメータ、Buffer、子モジュール
- モデルの保存、読み込み、デバイス移動、モード切り替え
- モデル容器:Sequential、ModuleList、ModuleDict
- CNNの全体像
- 畳み込みの基本
- Conv1d、Conv2d、Conv3dの違い
nn.Conv2dの入力、出力、パラメータ- Conv2dの出力サイズを計算する
- Conv2dのパラメータ数を計算する
- groups、depthwise、dilation
- 転置畳み込み
- プーリング層
- MaxUnpool2d
- Linear層
- 活性化関数
- 小さなCNNを組み立てる
- 形状をHookで確認する
- よくあるつまずきどころ
- 参考リンク
- おわりに
この記事で扱うこと
この記事では、次の内容を扱います。
-
nn.Moduleの役割 -
__init__()とforward()の書き方 -
nn.Parameterと通常のTensor属性の違い - Bufferとは何か
-
state_dict()による保存と読み込み -
model.to(device)、model.train()、model.eval()の意味 -
Sequential、ModuleList、ModuleDictの使い分け - CNNで画像Tensorがどのように変換されるか
-
Conv1d、Conv2d、Conv3dの違い -
nn.Conv2dの主要パラメータ - 畳み込みの出力サイズとパラメータ数
- グループ畳み込み、depthwise convolution、dilation
-
ConvTranspose2dの考え方 - MaxPool、AvgPool、Adaptive Pooling
-
nn.Linearの入力形状と重み形状 - Sigmoid、Tanh、ReLU、LeakyReLU、PReLU、RReLU、GELU、SiLU
- 小さなCNNの実装例
- Hookを使った形状確認
コードは小さく分けて置きます。各知識点を読むときに、その場で対応するコードを確認できるようにしています。
第3回から第4回へのつながり
第3回で作ったデータパイプラインでは、画像は最終的に次のようなTensorとしてモデルに渡されます。
images.shape = (N, 3, 224, 224)
labels.shape = (N,)
ここで、N はbatch size、3 はRGBのチャンネル数、224 と 224 は高さと幅です。
今回扱うモデルは、この images を受け取り、各サンプルについて2クラス分のlogitを返します。
入力: (N, 3, 224, 224)
出力: (N, 2)
大まかな流れは次のようになります。
画像batch
-> Conv2dで局所特徴を抽出
-> ReLUで非線形性を入れる
-> Poolingで空間サイズを下げる
-> さらにConv2dで特徴を組み合わせる
-> AdaptiveAvgPoolで固定サイズにする
-> Flattenでベクトルにする
-> Linearでクラスlogitにする
この流れを nn.Module として書けるようになることが、今回の目標です。
PyTorchでモデルを作る基本フロー
PyTorchでモデルを作るときは、次の順番で考えると整理しやすいです。
- 入力Tensorの形を確認する
- 必要な層を選ぶ
-
__init__()で子モジュールを登録する -
forward()でデータの流れを書く - 必要に応じて重みを初期化する
- モデルをデバイスへ移動する
- 損失関数と最適化手法を用意する
-
train()とeval()を切り替えながら訓練・評価する
最小のテンプレートは次の形です。
import torch
from torch import nn
class SimpleNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1), # 3チャンネル入力を32チャンネルの特徴マップに変える
nn.ReLU(), # 非線形性を入れる
nn.MaxPool2d(kernel_size=2, stride=2), # 高さ・幅を半分にする
)
self.pool = nn.AdaptiveAvgPool2d((1, 1)) # 高さ・幅を1x1にそろえ、画像サイズによらず特徴数を固定する
self.classifier = nn.Linear(32, num_classes) # 32チャンネル分の特徴をクラス数分のlogitに変換する
def forward(self, x):
x = self.features(x) # 畳み込み+活性化+プーリングを通す
x = self.pool(x) # (N, 32, 1, 1) にする
x = torch.flatten(x, start_dim=1) # (N, 32) にする
x = self.classifier(x) # (N, num_classes) のlogitにする
return x
model = SimpleNet(num_classes=10)
x = torch.randn(8, 3, 224, 224) # (N, C, H, W) = (8, 3, 224, 224) のダミー画像batch
logits = model(x) # forward() が呼ばれ、クラス数分のlogitが返る
print(logits.shape)
# torch.Size([8, 10])
出力は次のようになります。
torch.Size([8, 10])
super().__init__() は、子モジュールやパラメータを登録する仕組みを初期化します。self.conv = nn.Conv2d(...) のように層を属性として代入する前に呼びます。
ここでは、データ準備、モデル作成、損失関数、最適化手法、訓練ループを5ステップに分けて組み立てます。この記事では画像データを配布できないため、乱数Tensorを使って同じ構造だけを確認します。第3回の BanknoteDataset と DataLoader を用意できている場合は、dummy_loader の部分を実データの train_loader に置き換えます。
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(42)
# step 1: データを用意する
# Qiita記事では画像データを配布しないため、ここでは形だけ同じダミーデータを使います。
# 実データでは、第3回で作った Dataset / DataLoader をここに入れます。
dummy_images = torch.randn(64, 3, 32, 32)
dummy_labels = torch.randint(0, 2, (64,))
dummy_loader = DataLoader(
TensorDataset(dummy_images, dummy_labels),
batch_size=16,
shuffle=True,
)
# step 2: モデルを作る
# 32x32入力を想定した小さなCNNです。最後は2クラス分のlogitを返します。
class TinyCNN(nn.Module):
def __init__(self, num_classes=2):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 16, kernel_size=3, padding=1), # 3チャンネル入力を16チャンネルの特徴マップに変える
nn.ReLU(), # 非線形性を入れる
nn.MaxPool2d(kernel_size=2), # 高さ・幅を半分にする
nn.Conv2d(16, 32, kernel_size=3, padding=1), # 16チャンネルを32チャンネルに変える
nn.ReLU(), # 非線形性を入れる
nn.AdaptiveAvgPool2d((1, 1)), # 高さ・幅を1x1にそろえる
)
self.classifier = nn.Linear(32, num_classes) # 32チャンネル分の特徴をクラス数分のlogitに変換する
def forward(self, x):
x = self.features(x) # 畳み込み+活性化+プーリングを通す
x = torch.flatten(x, start_dim=1) # (N, 32, 1, 1) を (N, 32) にする
return self.classifier(x) # (N, num_classes) のlogitを返す
model = TinyCNN(num_classes=2)
# step 3: 損失関数を選ぶ
# CrossEntropyLossはlogitと整数ラベルを受け取ります。
criterion = nn.CrossEntropyLoss()
# step 4: 最適化手法を作る
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# step 5: 訓練ループを書く
model.train() # DropoutやBatchNormを訓練モードにする
for images, labels in dummy_loader:
logits = model(images) # 順伝播でlogitを計算する
loss = criterion(logits, labels) # logitと正解ラベルから損失を計算する
optimizer.zero_grad(set_to_none=True) # 前のbatchの勾配をリセットする
loss.backward() # 逆伝播で各パラメータの勾配を計算する
optimizer.step() # 勾配を使ってパラメータを更新する
print(loss.item())
# 0.6937180757522583(torch.manual_seed(42)によりこの通りに再現されます)
このコードではデータが乱数なので、精度そのものに意味はありません。重要なのは、DataLoader -> model -> loss -> backward -> optimizer.step() という流れが、実データでも同じになる点です。
torch.nn の主な構成要素
モデルとその訓練ループの最小構成を確認できたところで、次はここで使った nn.Module や nn.Linear がまとまっている torch.nn 全体を見渡します。PyTorchのニューラルネットワーク関連機能は、主に torch.nn にまとまっています。
| 要素 | 役割 |
|---|---|
nn.Module |
モデルと層の基底クラス |
nn.Parameter |
学習されるTensorを表すクラス |
nn.Sequential |
層を順番に実行する容器 |
nn.ModuleList |
層のリストを登録する容器 |
nn.ModuleDict |
層を名前つきで登録する容器 |
nn.Conv2d |
画像向けの2次元畳み込み層 |
nn.MaxPool2d |
局所領域の最大値を取る層 |
nn.Linear |
最後の次元に対する線形変換 |
nn.ReLU など |
活性化関数 |
torch.nn.functional |
関数として使う層・演算 |
nn.init |
パラメータ初期化関数 |
モジュール版と関数版は、次のように使い分けるとわかりやすいです。
import torch.nn.functional as F
x = F.relu(x)
x = F.max_pool2d(x, kernel_size=2)
状態やパラメータを持つもの、モデル構造として見せたいもの、Hookを付けたいものは nn.Module 版を使います。単純な無状態演算なら torch.nn.functional 版でも自然です。
nn.Module の基本
torch.nn の全体像を確認できたところで、次はその中心にある nn.Module を詳しく見ていきます。nn.Module は、PyTorchのモデル体系の中心です。自分でモデルを書くときは、nn.Module を継承します。
from torch import nn
class MLP(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
__init__() では、モデルが持つ層やパラメータを登録します。forward() では、入力Tensorがどの順番で処理されるかを書きます。
モデルを呼ぶときは、次のように書きます。
output = model(input_tensor)
model(input_tensor) は、PyTorchのモジュール呼び出し機構を通ってから forward() を実行します。Hookやモード管理と一緒に動くため、日常コードではこの呼び方を使います。
nn.Module は、単一の層だけを表すものではありません。次のどれも Module として表せます。
| 種類 | 例 |
|---|---|
| 1つの層 |
nn.Conv2d、nn.Linear
|
| 構造ブロック | Conv-BatchNorm-ReLU のまとまり |
| 完全なモデル | 画像分類CNN |
| 状態を持つ処理 | 平均値をBufferとして持つ前処理 |
| 分岐を持つモデル | 複数タスク用のヘッド |
パラメータ、Buffer、子モジュール
nn.Module が便利なのは、属性として代入されたものを適切に登録してくれるからです。
Parameter
nn.Parameter は、学習対象として扱われるTensorです。Moduleの属性として代入すると、model.parameters() や state_dict() に入ります。
import torch
from torch import nn
class Scale(nn.Module):
def __init__(self):
super().__init__()
self.scale = nn.Parameter(torch.ones(()))
def forward(self, x):
return x * self.scale
module = Scale()
for name, parameter in module.named_parameters():
print(name, parameter.shape)
# scale torch.Size([])
普通のTensorを属性として持たせても、自動では学習パラメータになりません。
class NotAParameter(nn.Module):
def __init__(self):
super().__init__()
self.scale = torch.ones(())
この scale は、最適化対象にも state_dict() のパラメータにも入りません。
Buffer
Bufferは、学習されるパラメータではないものの、モデルの状態として一緒に持っておきたいTensorです。代表例はBatchNormの移動平均や移動分散です。
自分で登録する場合は、register_buffer() を使います。
class Centering(nn.Module):
def __init__(self, mean):
super().__init__()
self.register_buffer("mean", torch.as_tensor(mean, dtype=torch.float32))
def forward(self, x):
return x - self.mean
Bufferは model.to(device) に追従し、通常は state_dict() にも入ります。ただし、最適化器が更新する対象ではありません。
子モジュール
nn.Conv2d や nn.Linear などをModuleの属性として代入すると、子モジュールとして登録されます。
self.conv = nn.Conv2d(3, 16, kernel_size=3, padding=1)
登録された子モジュールは、次の操作に再帰的に参加します。
model.parameters()model.state_dict()model.to(device)model.train()model.eval()
普通のPython listに層を入れると、PyTorchから見えなくなります。層をリスト状に持ちたい場合は nn.ModuleList を使います。
モデルの保存、読み込み、デバイス移動、モード切り替え
パラメータ、Buffer、子モジュールがどう登録されるかを確認できたところで、次はそれらを使った保存、読み込み、デバイス移動、モード切り替えの実際の操作を見ていきます。
state_dict()
state_dict() は、モデルのパラメータと持続的なBufferを名前つきで集めた辞書です。
model = SimpleNet(num_classes=10)
for name, tensor in model.state_dict().items():
print(name, tensor.shape)
# features.0.weight torch.Size([32, 3, 3, 3])
# features.0.bias torch.Size([32])
# classifier.weight torch.Size([10, 32])
# classifier.bias torch.Size([10])
重みを保存するときは、次の形がよく使われます。
torch.save(model.state_dict(), "simple_net_weights.pt")
読み込むときは、同じ構造のモデルを作ってから読み込みます。
model = SimpleNet(num_classes=10)
state = torch.load("simple_net_weights.pt", map_location="cpu", weights_only=True)
model.load_state_dict(state)
state_dict() はPythonオブジェクト全体ではなく、名前つきTensorの集合です。そのため、保存と読み込みの見通しがよくなります。
デバイス移動
モデルと入力Tensorは同じデバイスに置きます。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)
images = images.to(device)
labels = labels.to(device)
model.to(device) は、登録済みのパラメータ、Buffer、子モジュールを再帰的に移動します。普通のlistに入れただけの層は移動対象として扱われません。
train() と eval()
model.train() と model.eval() は、モジュールの訓練モードと評価モードを切り替えます。
model.train()
logits = model(images)
model.eval()
with torch.inference_mode():
logits = model(images)
この切り替えは、DropoutやBatchNormのようにモードで動きが変わる層に影響します。一方で、eval() は自動微分そのものを止めません。評価や推論では、torch.inference_mode() も一緒に使うと余計な勾配記録を避けられます。
パラメータを凍結する
一部の層を更新したくない場合は、requires_grad_() を使います。
for parameter in model.features.parameters():
parameter.requires_grad_(False)
勾配を止めることと、train() / eval() の切り替えは別の話です。BatchNormを含む部分を凍結する場合は、統計量を更新するかどうかも考えます。
モデル容器:Sequential、ModuleList、ModuleDict
複数の層をまとめたいときは、モデル容器を使います。
Sequential
nn.Sequential は、登録したモジュールを順番に実行します。
block = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2),
)
名前を付けたい場合は、OrderedDict を使えます。
from collections import OrderedDict
block = nn.Sequential(OrderedDict([
("conv", nn.Conv2d(3, 32, kernel_size=3, padding=1)),
("relu", nn.ReLU()),
("pool", nn.MaxPool2d(kernel_size=2)),
]))
Sequential は、単純な1本道の処理に向いています。途中で分岐する、入力が複数ある、スキップ接続がある、条件で層を選ぶ、といった場合は自分で forward() を書くほうが自然です。
LeNet風のモデルを Sequential で書くと、次のようになります。入力は (N, 3, 32, 32) を想定します。
import torch
from torch import nn
from collections import OrderedDict
class LeNetSequential(nn.Module):
def __init__(self, num_classes=2):
super().__init__()
# 画像から局所特徴を取り出す部分です。
# 32x32 -> Conv5x5で28x28 -> Poolで14x14
# 14x14 -> Conv5x5で10x10 -> Poolで5x5 になります。
self.features = nn.Sequential(
nn.Conv2d(3, 6, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2),
)
# featuresの出力は (N, 16, 5, 5) なので、Linearへ渡す特徴数は16*5*5です。
self.classifier = nn.Sequential(
nn.Linear(16 * 5 * 5, 120),
nn.ReLU(),
nn.Linear(120, 84),
nn.ReLU(),
nn.Linear(84, num_classes),
)
def forward(self, x):
x = self.features(x) # 畳み込み+プーリングで局所特徴を取り出す
x = torch.flatten(x, start_dim=1) # (N, 16, 5, 5) を (N, 16*5*5) に平坦化する
return self.classifier(x) # Linearを重ねてクラス数分のlogitにする
net = LeNetSequential(num_classes=2)
fake_images = torch.randn(4, 3, 32, 32)
logits = net(fake_images)
print(logits.shape)
# torch.Size([4, 2])
出力は次のようになります。
torch.Size([4, 2])
層に名前を付けておきたい場合は、OrderedDict を使います。Hookや中間特徴の取り出しで名前を見たいときに便利です。
features = nn.Sequential(OrderedDict([
("conv1", nn.Conv2d(3, 6, kernel_size=5)),
("relu1", nn.ReLU()),
("pool1", nn.MaxPool2d(kernel_size=2, stride=2)),
("conv2", nn.Conv2d(6, 16, kernel_size=5)),
("relu2", nn.ReLU()),
("pool2", nn.MaxPool2d(kernel_size=2, stride=2)),
]))
print(features)
# Sequential(
# (conv1): Conv2d(3, 6, kernel_size=(5, 5), stride=(1, 1))
# (relu1): ReLU()
# (pool1): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
# (conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1))
# (relu2): ReLU()
# (pool2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
# )
ModuleList
nn.ModuleList は、層をlistのように保持しながら、PyTorchの子モジュールとして登録します。ただし、自動で順番に実行してくれるわけではありません。
class RepeatedMLP(nn.Module):
def __init__(self, width, depth):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(width, width) for _ in range(depth)
])
def forward(self, x):
for layer in self.layers:
x = torch.relu(layer(x))
return x
深さを引数で変えたいモデルや、ループで層を呼びたいモデルに向いています。
ModuleList では、保存と登録はされますが、実行順は自分で書きます。次のコードでは20個の Linear を順番に呼び出しています。
class LinearStack(nn.Module):
def __init__(self, width=10, depth=20):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(width, width) for _ in range(depth)
])
def forward(self, x):
# ModuleListは自動ではforwardされないため、for文で明示的に呼びます。
for layer in self.layers:
x = layer(x)
return x
net = LinearStack(width=10, depth=20)
fake_data = torch.ones(10, 10)
output = net(fake_data)
print(output.shape)
# torch.Size([10, 10])
ModuleDict
nn.ModuleDict は、層を名前つきで保持します。タスク名や設定に応じて層を選びたいときに使いやすいです。
class MultiHead(nn.Module):
def __init__(self, width):
super().__init__()
self.heads = nn.ModuleDict({
"classify": nn.Linear(width, 10),
"regress": nn.Linear(width, 1),
})
def forward(self, x, task):
return self.heads[task](x)
画像処理の例に寄せると、ModuleDict は「畳み込みを使うか、プーリングを使うか」「どの活性化関数を使うか」をキーで選ぶような書き方にも使えます。
class SelectableBlock(nn.Module):
def __init__(self):
super().__init__()
self.choices = nn.ModuleDict({
"conv": nn.Conv2d(10, 10, kernel_size=3),
"pool": nn.MaxPool2d(kernel_size=3),
})
self.activations = nn.ModuleDict({
"relu": nn.ReLU(),
"prelu": nn.PReLU(),
})
def forward(self, x, choice, activation):
# choiceとactivationの文字列で、使うモジュールを選びます。
x = self.choices[choice](x)
x = self.activations[activation](x)
return x
net = SelectableBlock()
fake_images = torch.randn(4, 10, 32, 32)
output = net(fake_images, choice="conv", activation="relu")
print(output.shape)
# torch.Size([4, 10, 30, 30])
3つの容器をまとめると、次のようになります。
| 容器 | 登録 | 自動forward | 向いている場面 |
|---|---|---|---|
Sequential |
する | する | 単純な順序処理 |
ModuleList |
する | しない | ループ、可変深さ |
ModuleDict |
する | しない | 名前で選ぶ分岐、複数ヘッド |
| Python list/dict | しない | しない | モデル層の保存には向かない |
CNNの全体像
モデルを組み立てるための部品(Sequential、ModuleList、ModuleDict)を確認できたところで、次はそれらの部品を使って組み立てるCNN全体の流れを見ていきます。CNNは、画像の局所的なパターンを段階的に抽出するネットワークです。浅い層ではエッジや色の変化、深い層ではそれらを組み合わせた形や部品のような特徴を扱いやすくなります。
画像分類CNNの典型的な流れは次の通りです。
Input (N, 3, H, W)
-> Conv2d
-> ReLU
-> MaxPool2d
-> Conv2d
-> ReLU
-> MaxPool2d
-> Conv2d
-> ReLU
-> AdaptiveAvgPool2d
-> Flatten
-> Linear
-> logits
ここで重要なのは、各層がTensorのどの次元を変えるかです。
| 層 | 主に変えるもの |
|---|---|
Conv2d |
チャンネル数、場合により高さ・幅 |
ReLU |
値そのもの。形状は変えない |
MaxPool2d |
高さ・幅を小さくする |
AdaptiveAvgPool2d |
高さ・幅を指定サイズにする |
Flatten |
チャンネル・高さ・幅を1本の特徴ベクトルにする |
Linear |
最後の特徴次元をクラス数などへ変換する |
この「形がどう変わるか」を追う習慣が、CNNのデバッグではとても大切です。
畳み込みの基本
畳み込み層は、小さなカーネルを入力の上で滑らせながら、局所領域の特徴を取り出します。画像では、カーネルは高さと幅の方向に移動します。
概念的には、1つの出力位置で次の計算をしています。
入力の局所領域: C_in x K_h x K_w
要素ごとに掛ける
カーネル重み: C_in x K_h x K_w
合計し、biasを足す
出力位置: 1つの値
これをすべての位置、すべての出力チャンネルで繰り返します。
画像1枚を考えると、入力と出力は次のように見られます。
入力: (C_in, H_in, W_in)
出力: (C_out, H_out, W_out)
batchつきでは次の形です。
入力: (N, C_in, H_in, W_in)
出力: (N, C_out, H_out, W_out)
C_out は、出力特徴マップの枚数です。たとえば out_channels=32 なら、32種類の特徴マップを作ります。
畳み込みの強みは、局所接続と重み共有にあります。同じカーネルを画像のあちこちで使うため、画像内の場所が少し変わっても似た特徴を検出しやすくなります。
畳み込みが実際に何をしているかを、1チャンネルの小さな例で手計算してみます。PyTorchの Conv2d は、この計算を全位置・全出力チャンネルに対して効率よく行っています。
import torch
from torch import nn
# 入力は1枚、1チャンネル、高さ4、幅4のTensorです。
# shapeは (N, C, H, W) なので、ここでは (1, 1, 4, 4) です。
x = torch.tensor([[[
[1.0, 2.0, 3.0, 4.0],
[5.0, 6.0, 7.0, 8.0],
[9.0, 10.0, 11.0, 12.0],
[13.0, 14.0, 15.0, 16.0],
]]])
# 出力チャンネル1、入力チャンネル1、kernel_size=2 の畳み込みです。
# bias=Falseにして、手計算と比較しやすくします。
conv = nn.Conv2d(
in_channels=1, # 入力は1チャンネルだけ
out_channels=1, # 出力も1チャンネルだけ
kernel_size=2, # 2x2のカーネル
stride=1, # 1画素ずつカーネルをずらす
padding=0, # 余白は足さない
bias=False, # biasを外し、手計算と直接比較できるようにする
)
# カーネルの重みを手で固定します。
# weight.shape は (out_channels, in_channels, kernel_h, kernel_w) です。
with torch.no_grad():
conv.weight.copy_(torch.tensor([[[
[1.0, 0.0], # カーネル1行目
[0.0, -1.0], # カーネル2行目
]]]))
y = conv(x) # 畳み込みを実行する。出力は (1, 1, 3, 3) になる
# 左上の出力だけを手で計算します。
# 入力の左上2x2は [[1, 2], [5, 6]] です。
# カーネルは [[1, 0], [0,-1]] です。
# 要素ごとに掛けて足すので、1*1 + 2*0 + 5*0 + 6*(-1) = -5 です。
manual_top_left = 1.0 * 1.0 + 2.0 * 0.0 + 5.0 * 0.0 + 6.0 * (-1.0)
print(y) # Conv2dの出力(3x3、値はすべて-5になるはず)
# tensor([[[[-5., -5., -5.],
# [-5., -5., -5.],
# [-5., -5., -5.]]]], grad_fn=<ConvolutionBackward0>)
print(manual_top_left) # 手計算した左上の値(-5になるはず)
# -5.0
出力は次のようになります。
tensor([[[[-5., -5., -5.],
[-5., -5., -5.],
[-5., -5., -5.]]]], grad_fn=<ConvolutionBackward0>)
-5.0
ここでは入力が規則的に増えており、同じカーネルをどの位置に当てても差が -5 になるため、出力全体が -5 になっています。実際の画像では、場所ごとの局所パターンが違うため、出力特徴マップにも場所ごとの差が出ます。
Conv1d、Conv2d、Conv3dの違い
Conv1d、Conv2d、Conv3d は、カーネルが滑る次元数で分かれます。
| 層 | 典型入力 | 滑る次元 | 主な用途 |
|---|---|---|---|
nn.Conv1d |
(N, C, L) |
1 | 音声、時系列、テキスト特徴列 |
nn.Conv2d |
(N, C, H, W) |
2 | 画像、2次元特徴マップ |
nn.Conv3d |
(N, C, D, H, W) |
3 | 動画、3D医用画像、体積データ |
チャンネル方向は、カーネルが平行移動する方向ではありません。チャンネル方向は、局所領域の値の重み付き和を取る方向です。
画像分類では、まず Conv2d をしっかり理解するのが近道です。
nn.Conv2d の入力、出力、パラメータ
nn.Conv2d の基本形は次の通りです。
from torch import nn
conv = nn.Conv2d(
in_channels=3, # 入力チャンネル数(RGB画像なら3)
out_channels=16, # 出力チャンネル数(作る特徴マップの枚数)
kernel_size=3, # カーネルの高さ・幅
stride=1, # カーネルを動かす歩幅
padding=1, # 入力の周囲に足す余白
dilation=1, # カーネル要素どうしの間隔
groups=1, # 入力チャンネルと出力チャンネルの接続分割数
bias=True, # 出力チャンネルごとのbiasを持つ
padding_mode="zeros", # 余白を0で埋める
)
主要パラメータを順番に見ます。
| 引数 | 意味 |
|---|---|
in_channels |
入力チャンネル数。RGB画像なら3 |
out_channels |
出力チャンネル数。作る特徴マップの数 |
kernel_size |
カーネルの高さ・幅 |
stride |
カーネルを動かす歩幅 |
padding |
入力の周囲に足す余白 |
dilation |
カーネル要素どうしの間隔 |
groups |
入力チャンネルと出力チャンネルの接続分割数 |
bias |
出力チャンネルごとのbiasを持つか |
padding_mode |
余白の埋め方 |
in_channels
入力のチャンネル数です。RGB画像を最初に受ける層なら、通常は 3 です。
conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3) # 入力はRGBの3チャンネルを想定
この層に (N, 1, H, W) のグレースケール画像を渡すと、チャンネル数が合わずにエラーになります。
out_channels
出力する特徴マップの数です。out_channels=16 なら、16枚の特徴マップを作ります。
入力: (N, 3, H, W)
出力: (N, 16, H_out, W_out)
out_channels を大きくすると、表現できる特徴の種類は増えますが、パラメータ数と計算量も増えます。
kernel_size
カーネルの大きさです。
nn.Conv2d(3, 16, kernel_size=3) # 高さ3、幅3の正方形カーネル
nn.Conv2d(3, 16, kernel_size=(3, 5)) # 高さ3、幅5の長方形カーネル
kernel_size=3 は、高さ3、幅3のカーネルを意味します。(3, 5) と書くと、高さ3、幅5です。
stride
カーネルを何画素ずつ動かすかを指定します。
nn.Conv2d(3, 16, kernel_size=3, stride=1) # 1画素ずつ動かす(サイズはあまり縮まない)
nn.Conv2d(3, 16, kernel_size=3, stride=2) # 2画素ずつ動かす(サイズがおおむね半分になる)
stride=2 にすると、空間サイズはおおむね半分になります。特徴抽出と同時に下げたいときに使います。
padding
入力の周囲に余白を足します。
nn.Conv2d(3, 16, kernel_size=3, padding=1) # 周囲に1画素分の余白を足し、高さ・幅を保つ
kernel_size=3、stride=1、dilation=1 の場合、padding=1 にすると高さと幅を保ちやすくなります。
入力: (N, 3, 32, 32)
出力: (N, 16, 32, 32)
padding=0 では、端の画素ほど使われる回数が少なくなり、出力サイズも小さくなります。
dilation
dilation は、カーネルの要素どうしの間隔を広げます。dilated convolutionとも呼ばれます。
nn.Conv2d(3, 16, kernel_size=3, dilation=2) # カーネル要素の間隔を2にし、有効サイズを5にする
有効カーネルサイズは次の式で考えられます。
K_eff = dilation * (kernel_size - 1) + 1
kernel_size=3、dilation=2 なら、実際に見る範囲は5になります。ただし、重みを持つ位置は3点分です。広い範囲を見たいが、パラメータ数を増やしすぎたくないときに使われます。
groups
groups は、入力チャンネルと出力チャンネルの接続を分けます。
nn.Conv2d(16, 32, kernel_size=3, padding=1, groups=1) # 通常の畳み込み(全チャンネルが接続)
nn.Conv2d(16, 32, kernel_size=3, padding=1, groups=4) # 4グループに分けて接続を制限する
groups=1 では、すべての入力チャンネルがすべての出力チャンネルに接続します。groups>1 にすると、チャンネルをグループに分け、それぞれの中だけで畳み込みを行います。
制約は次の通りです。
in_channels % groups == 0
out_channels % groups == 0
満たしていないと、層を作る時点または実行時にエラーになります。
重みとbiasの形
Conv2d の重みは次の形で保存されます。
weight.shape = (out_channels, in_channels / groups, kernel_h, kernel_w)
bias.shape = (out_channels,)
実際に確認してみます。
conv = nn.Conv2d(3, 16, kernel_size=3, padding=1)
print(conv.weight.shape) # (out_channels, in_channels, kernel_h, kernel_w)
# torch.Size([16, 3, 3, 3])
print(conv.bias.shape) # (out_channels,)
# torch.Size([16])
出力は次のようになります。
torch.Size([16, 3, 3, 3])
torch.Size([16])
16 は出力チャンネル数、3 は入力チャンネル数、残りの 3, 3 はカーネルの高さと幅です。
この記事では画像ファイルを同梱できないため、次の例では乱数の画像Tensorで「畳み込み前後のshape」と「重みの初期化」を確認します。手元に画像がある場合は、image_tensor をPillowで読み込んだTensorに置き換えれば同じ流れで使えます。
import torch
from torch import nn
torch.manual_seed(3) # 乱数を固定し、実行結果を再現しやすくする
# ダミーのRGB画像batchです。
# 実画像を使う場合も、モデルへ入れる直前のshapeは (N, C, H, W) にそろえます。
image_tensor = torch.rand(1, 3, 128, 128)
# 3チャンネル入力を受け取り、1チャンネルの特徴マップを作る畳み込みです。
conv_layer = nn.Conv2d(
in_channels=3, # RGBの3チャンネル
out_channels=1, # 特徴マップは1枚だけ作る
kernel_size=3, # 3x3カーネル
stride=1, # 1画素ずつ動かす
padding=0, # 余白なし(出力は入力より小さくなる)
)
# Xavier初期化の例です。通常は層のデフォルト初期化でも動きますが、
# 初期化関数を使うと、意図した分布で重みをセットできます。
nn.init.xavier_normal_(conv_layer.weight)
image_conv = conv_layer(image_tensor) # 畳み込みを実行する
print("畳み込み前:", image_tensor.shape)
# 畳み込み前: torch.Size([1, 3, 128, 128])
print("畳み込み後:", image_conv.shape)
# 畳み込み後: torch.Size([1, 1, 126, 126])
print("重み:", conv_layer.weight.shape)
# 重み: torch.Size([1, 3, 3, 3])
kernel_size=3、padding=0、stride=1 なので、高さと幅は 128 -> 126 になります。出力チャンネル数は out_channels=1 によって決まります。
Conv2dの出力サイズを計算する
nn.Conv2d の各引数の意味を確認できたところで、次はそれらの引数が出力のshapeにどう影響するかを具体的に計算していきます。畳み込みの出力サイズは、次の式で計算できます。高さと幅それぞれに同じ形の式を使います。
out = floor((in + 2*padding - dilation*(kernel_size - 1) - 1) / stride + 1)
有効カーネルサイズ K_eff を使うと、少し見やすくなります。
K_eff = dilation * (kernel_size - 1) + 1
out = floor((in + 2*padding - K_eff) / stride + 1)
例を見ます。
import torch
from torch import nn
conv = nn.Conv2d(3, 16, kernel_size=3, stride=2, padding=1) # stride=2で空間サイズを縮める
x = torch.randn(8, 3, 32, 32) # (N, C, H, W) = (8, 3, 32, 32)
y = conv(x) # 出力サイズは下の式で計算できる
print(y.shape)
# torch.Size([8, 16, 16, 16])
出力は次の通りです。
torch.Size([8, 16, 16, 16])
高さ方向だけ計算すると、次のようになります。
in = 32
padding = 1
kernel_size = 3
dilation = 1
stride = 2
out = floor((32 + 2*1 - 1*(3 - 1) - 1) / 2 + 1)
= floor((32 + 2 - 2 - 1) / 2 + 1)
= floor(31 / 2 + 1)
= floor(16.5)
= 16
幅も同じなので、出力は (8, 16, 16, 16) です。
出力サイズが合わないときは、kernel_size、stride、padding、dilation のどれかを見直します。とくに stride と padding は、空間サイズの変化に強く影響します。
Conv2dのパラメータ数を計算する
畳み込みのパラメータ数は、入力画像の高さや幅には直接依存しません。カーネルの大きさ、入力チャンネル数、出力チャンネル数、groupsで決まります。
weight parameters = out_channels * (in_channels / groups) * K_h * K_w
bias parameters = out_channels # bias=True の場合
たとえば次の層を考えます。
conv = nn.Conv2d(3, 16, kernel_size=3, padding=1, bias=True) # 3x3カーネル、bias付き
重みの数は次の通りです。
16 * 3 * 3 * 3 = 432
biasは出力チャンネルごとに1つなので、16個です。合計は448個になります。
# conv.parameters()には重みとbiasの両方が含まれるので、numel()の合計がパラメータ総数になる
total_parameters = sum(parameter.numel() for parameter in conv.parameters())
print(total_parameters)
# 448
448
畳み込みのパラメータ数は画像サイズに依存しませんが、計算量は出力の高さと幅に依存します。大きい画像を大きい特徴マップのまま処理すると、計算時間とメモリ使用量は増えます。
groups、depthwise、dilation
出力サイズとパラメータ数の計算方法を確認できたところで、次は groups や dilation によって畳み込みの接続方法自体を変える方法を見ていきます。
通常の畳み込み
groups=1 が通常の畳み込みです。
すべての入力チャンネル
-> 各出力チャンネルへ接続
RGB画像なら、R、G、Bの3チャンネルをまとめて見て、各出力チャンネルの特徴を作ります。
グループ畳み込み
groups を増やすと、入力チャンネルと出力チャンネルをグループに分けます。
groups=2:
入力チャンネル前半 -> 出力チャンネル前半
入力チャンネル後半 -> 出力チャンネル後半
グループ間は直接つながりません。パラメータ数と計算量を減らせますが、チャンネル間の情報交換も減ります。
group_conv = nn.Conv2d(
in_channels=16, # 入力16チャンネルを4グループに分ける
out_channels=32, # 出力32チャンネルも同様に4グループに分ける
kernel_size=3,
padding=1,
groups=4, # グループ数。各グループ内だけで畳み込みを行う
)
print(group_conv.weight.shape) # (32, 16/4, 3, 3) = (32, 4, 3, 3) になるはず
# torch.Size([32, 4, 3, 3])
この場合、各グループの入力チャンネルは 16 / 4 = 4 なので、重みの形は次のようになります。
torch.Size([32, 4, 3, 3])
depthwise convolution
groups=in_channels にすると、入力チャンネルごとに独立した畳み込みになります。これをdepthwise convolutionと呼びます。
depthwise = nn.Conv2d(
in_channels=32,
out_channels=32,
kernel_size=3,
padding=1,
groups=32, # in_channelsと同じ数にすると、チャンネルごとに独立した畳み込みになる
)
各チャンネルを別々に畳み込むため、チャンネル同士の混合は行われません。チャンネルを混ぜたい場合は、次に 1x1 畳み込みを置きます。
depthwise_separable = nn.Sequential(
nn.Conv2d(32, 32, kernel_size=3, padding=1, groups=32, bias=False), # 空間方向だけを処理(depthwise)
nn.Conv2d(32, 64, kernel_size=1, bias=False), # 1x1畳み込みでチャンネルを混合(pointwise)
nn.BatchNorm2d(64), # チャンネルごとに正規化する
nn.ReLU(), # 非線形性を入れる
)
この組み合わせは、depthwise separable convolutionと呼ばれます。空間方向の処理とチャンネル方向の混合を分けて考えられるのが特徴です。
dilation
dilation は、カーネルの内部に間隔を作って、より広い範囲を見るための設定です。
dilation=1: 連続した3x3を見る
dilation=2: 1つ飛ばしで3x3分の点を見る
dilated = nn.Conv2d(
in_channels=64,
out_channels=64,
kernel_size=3,
padding=2, # 有効カーネルサイズ5に合わせて余白を広げ、高さ・幅を保つ
dilation=2, # カーネル要素の間隔を2にする
)
kernel_size=3、dilation=2 の有効カーネルサイズは5です。空間サイズを保ちたい場合は、対応するpaddingも大きくします。
K_eff = 2 * (3 - 1) + 1 = 5
転置畳み込み
順方向の畳み込みの派生形(groups、depthwise、dilation)を確認できたところで、次は方向を逆にして空間サイズを広げる転置畳み込みを見ていきます。転置畳み込みは、学習可能なアップサンプリングとしてよく使われます。普通の畳み込みを線形変換 K と見たとき、その転置に対応する演算として考えられます。
普通の畳み込み: y = Kx
転置畳み込み: z = K^T y
これは、元の入力を完全に復元するという意味ではありません。空間サイズを大きくするための学習可能な層として理解すると扱いやすいです。
nn.ConvTranspose2d の基本形は次の通りです。
up = nn.ConvTranspose2d(
in_channels=64, # 入力チャンネル数
out_channels=32, # 出力チャンネル数
kernel_size=4, # カーネルサイズ
stride=2, # 2倍にアップサンプリングする
padding=1, # 出力サイズの調整用
)
x = torch.randn(8, 64, 16, 16) # (N, C, H, W) = (8, 64, 16, 16)
y = up(x) # 空間サイズが16から32へ広がるはず
print(y.shape)
# torch.Size([8, 32, 32, 32])
出力は次のようになります。
torch.Size([8, 32, 32, 32])
出力サイズは、単一の空間次元について次の式で計算できます。
out = (in - 1) * stride
- 2 * padding
+ dilation * (kernel_size - 1)
+ output_padding
+ 1
kernel_size と stride の組み合わせによっては、出力の場所ごとにカーネルの重なり方が不均一になり、格子状のムラが出ることがあります。気になる場合は、補間でサイズを上げてから通常の畳み込みを置く方法もあります。
upsample_then_conv = nn.Sequential(
nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False),
nn.Conv2d(64, 32, kernel_size=3, padding=1),
)
転置畳み込みも、ここでは乱数Tensorを使って空間サイズの変化を確認します。
transposed = nn.ConvTranspose2d(
in_channels=3, # ダミー画像と同じ3チャンネル
out_channels=1, # 出力は1チャンネル
kernel_size=3,
stride=2, # 2倍にアップサンプリングする
)
image_tensor = torch.rand(1, 3, 32, 32) # ダミーのRGB画像1枚
image_up = transposed(image_tensor) # 32x32 -> 65x65に広がる
print("転置畳み込み前:", image_tensor.shape)
# 転置畳み込み前: torch.Size([1, 3, 32, 32])
print("転置畳み込み後:", image_up.shape)
# 転置畳み込み後: torch.Size([1, 1, 65, 65])
この設定では、空間サイズは次の式で 32 -> 65 になります。
out = (32 - 1) * 2 - 2*0 + 1*(3 - 1) + 0 + 1 = 65
プーリング層
プーリングは、局所領域を1つの値にまとめる処理です。畳み込みと違い、学習されるカーネル重みはありません。
MaxPool2d
MaxPool2d は、局所領域の最大値を取ります。
pool = nn.MaxPool2d(kernel_size=2, stride=2) # 2x2窓の最大値を取り、サイズを半分にする
x = torch.randn(8, 32, 64, 64) # (N, C, H, W) = (8, 32, 64, 64)
y = pool(x) # 高さ・幅がそれぞれ半分の32になる
print(y.shape)
# torch.Size([8, 32, 32, 32])
出力は次のようになります。
torch.Size([8, 32, 32, 32])
主な引数は次の通りです。
| 引数 | 意味 |
|---|---|
kernel_size |
プーリング窓の大きさ |
stride |
窓を動かす歩幅。省略時は kernel_size
|
padding |
入力端の扱い |
dilation |
窓内要素の間隔 |
return_indices |
最大値の位置を返すか |
ceil_mode |
出力サイズ計算で一部の端の扱いを変えるか |
AvgPool2d
AvgPool2d は、局所領域の平均を取ります。
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2) # 2x2窓の平均値を取る
# (N, C, H, W) = (1, 1, 4, 4) のダミー入力です。
x = torch.tensor([[[
[1.0, 2.0, 3.0, 4.0],
[5.0, 6.0, 7.0, 8.0],
[9.0, 10.0, 11.0, 12.0],
[13.0, 14.0, 15.0, 16.0],
]]])
y = avg_pool(x) # 各2x2窓の平均を計算する(左上は (1+2+5+6)/4=3.5)
print(y)
# tensor([[[[ 3.5000, 5.5000],
# [11.5000, 13.5000]]]])
最大値だけを残すMaxPoolに比べて、領域全体の平均的な情報を残します。
divisor_override を使うと、平均値を計算するときの分母を明示できます。通常は窓の要素数で割りますが、次の例では2x2窓の合計を3で割っています。
x = torch.ones(1, 1, 4, 4) # すべて1のダミー入力
avg_pool = nn.AvgPool2d(kernel_size=2, stride=2, divisor_override=3) # 分母を3に固定する
y = avg_pool(x) # 2x2窓の合計4を3で割るので、出力は4/3になる
print(y)
# tensor([[[[1.3333, 1.3333],
# [1.3333, 1.3333]]]])
2x2窓の合計は4なので、分母を3にすると出力は 4 / 3 になります。
Adaptive Pooling
AdaptiveAvgPool2d は、入力サイズに応じて窓と歩幅を調整し、出力サイズを指定した形にします。
pool = nn.AdaptiveAvgPool2d((1, 1)) # 出力の高さ・幅を1x1に固定する
x = torch.randn(8, 256, 7, 7) # (N, C, H, W) = (8, 256, 7, 7)
y = pool(x) # 入力サイズが何であっても (8, 256, 1, 1) になる
print(y.shape)
# torch.Size([8, 256, 1, 1])
出力は次の通りです。
torch.Size([8, 256, 1, 1])
分類モデルの最後で (N, C, H, W) を (N, C, 1, 1) にする使い方がよくあります。その後、torch.flatten(x, 1) で (N, C) にして Linear へ渡せます。
MaxUnpool2d
MaxUnpool2d は、MaxPool2d で得た最大値の位置を使って、値を元の空間位置に戻す層です。
pool = nn.MaxPool2d(2, stride=2, return_indices=True) # 最大値の位置(indices)も返す
unpool = nn.MaxUnpool2d(2, stride=2)
x = torch.tensor([[[[1.0, 2.0], [3.0, 4.0]]]]) # (N, C, H, W) = (1, 1, 2, 2)
y, indices = pool(x) # yは最大値(4.0)、indicesはその位置
z = unpool(y, indices, output_size=x.shape) # 記録した位置にだけ4.0を戻す
print(y)
# tensor([[[[4.]]]])
print(z)
# tensor([[[[0., 0.],
# [0., 4.]]]])
出力は概念的に次のようになります。
MaxPool: [[4.0]] と右下の位置
MaxUnpool: [[0.0, 0.0],
[0.0, 4.0]]
非最大値の 1.0、2.0、3.0 は戻りません。MaxUnpoolは、MaxPoolの完全な逆変換ではなく、最大値を記録位置に戻す処理です。
Linear層
プーリング関連の層を確認できたところで、次は特徴ベクトルをクラス数などへ変換する Linear 層を見ていきます。nn.Linear は、入力の最後の次元に線形変換をかけます。
linear = nn.Linear(in_features=128, out_features=10) # 128次元を10次元へ変換する
x = torch.randn(8, 128) # (N, in_features) = (8, 128)
y = linear(x) # y = x W^T + b により (8, 10) になる
print(y.shape)
# torch.Size([8, 10])
出力は次の通りです。
torch.Size([8, 10])
重みとbiasを手で固定すると、Linear が何を計算しているかを確認しやすくなります。
inputs = torch.tensor([[1.0, 2.0, 3.0]]) # (N, in_features) = (1, 3)
linear_layer = nn.Linear(in_features=3, out_features=4)
with torch.no_grad():
# weight.shape は (out_features, in_features) です。
# 1行目は [1, 1, 1] なので、入力との内積は 1+2+3 = 6 になります。
linear_layer.weight.copy_(torch.tensor([
[1.0, 1.0, 1.0], # 出力1番目の重み -> 1+2+3=6
[2.0, 2.0, 2.0], # 出力2番目の重み -> 2+4+6=12
[3.0, 3.0, 3.0], # 出力3番目の重み -> 3+6+9=18
[4.0, 4.0, 4.0], # 出力4番目の重み -> 4+8+12=24
]))
# 各出力に0.5を足します。
linear_layer.bias.fill_(0.5)
output = linear_layer(inputs) # 上の内積に0.5を足した値になるはず
print(inputs)
# tensor([[1., 2., 3.]])
print(linear_layer.weight)
# Parameter containing:
# tensor([[1., 1., 1.],
# [2., 2., 2.],
# [3., 3., 3.],
# [4., 4., 4.]], requires_grad=True)
print(output)
# tensor([[ 6.5000, 12.5000, 18.5000, 24.5000]], grad_fn=<AddmmBackward0>)
出力は次のようになります。
tensor([[ 6.5000, 12.5000, 18.5000, 24.5000]], grad_fn=<AddmmBackward0>)
たとえば1番目の出力は、1*1 + 2*1 + 3*1 + 0.5 = 6.5 です。
計算は次の形です。
y = x W^T + b
重みとbiasの形は次の通りです。
weight.shape = (out_features, in_features)
bias.shape = (out_features,)
Linear は、2次元Tensorだけに使う層ではありません。batch次元などの前側の次元はそのまま残し、最後の次元だけを変換します。
linear = nn.Linear(128, 64)
x = torch.randn(4, 20, 128) # (batch, seq_len, in_features) のような3次元入力
y = linear(x) # 最後の次元だけ128->64に変換され、前の次元(4, 20)は残る
print(y.shape)
# torch.Size([4, 20, 64])
出力は次の通りです。
torch.Size([4, 20, 64])
CNNの出力を Linear へ渡すときは、batch次元を残して平坦化します。
x = torch.randn(8, 128, 7, 7) # (N, C, H, W) = (8, 128, 7, 7)
x = torch.flatten(x, start_dim=1) # dim=1以降(C, H, W)をまとめて1本のベクトルにする
print(x.shape) # (8, 128*7*7) = (8, 6272)
torch.Size([8, 6272])
x.view(-1) のようにするとbatch次元までつぶれてしまうため、分類モデルでは通常使いません。
活性化関数
Linear で次元を変換する方法を確認できたところで、次はその出力に非線形性を加える活性化関数を見ていきます。活性化関数は、ネットワークに非線形性を入れます。もし層と層の間が線形変換だけなら、何層重ねても1つの線形変換にまとめられます。
H1 = XW1
H2 = H1W2
Y = H2W3
Y = X(W1W2W3)
つまり、深くしても表現力が増えにくくなります。活性化関数を挟むことで、より複雑な関係を表せるようになります。
Sigmoid
sigmoid(x) = 1 / (1 + exp(-x))
出力は (0, 1) なので、確率やゲート値として扱いやすい関数です。二値分類で確率を表示したいときにも使えます。
訓練で BCEWithLogitsLoss を使う場合は、モデルの出力にはSigmoidをかけず、logitのまま損失関数へ渡します。
Tanh
tanh(x) = (exp(x) - exp(-x)) / (exp(x) + exp(-x))
出力は (-1, 1) です。Sigmoidと違って出力が0を中心に広がります。
ReLU
ReLU(x) = max(0, x)
ReLUはCNNでよく使われる活性化関数です。
relu = nn.ReLU()
x = torch.tensor([-2.0, 0.0, 3.0])
print(relu(x))
# tensor([0., 0., 3.])
tensor([0., 0., 3.])
正の値はそのまま通し、負の値は0にします。計算が軽く、深いネットワークでも扱いやすいのが特徴です。
LeakyReLU、PReLU、RReLU
ReLUの負側を完全に0にせず、小さな傾きを持たせる変種もあります。
leaky_relu = nn.LeakyReLU(negative_slope=0.01)
prelu = nn.PReLU(num_parameters=1, init=0.25)
rrelu = nn.RReLU(lower=1/8, upper=1/3)
| 関数 | 負側の扱い |
|---|---|
LeakyReLU |
固定の小さな傾き |
PReLU |
傾きを学習する |
RReLU |
訓練時に範囲内からランダムに傾きを選ぶ |
GELU、SiLU、Mish
画像モデルやTransformerでは、滑らかな活性化関数もよく使われます。
gelu = nn.GELU()
silu = nn.SiLU()
mish = nn.Mish()
| 関数 | 特徴 |
|---|---|
GELU |
入力を滑らかにゲートする |
SiLU |
x * sigmoid(x) の形 |
Mish |
滑らかな非線形関数 |
最初はReLUを基本として理解し、モデルやタスクに応じて他の活性化関数を見ると整理しやすくなります。
小さなCNNを組み立てる
ここまでの内容を使って、画像分類用の小さなCNNを書きます。第3回のデータパイプラインから渡される (N, 3, 224, 224) の画像Tensorを想定します。
import torch
from torch import nn
class BanknoteCNN(nn.Module):
def __init__(self, num_classes=2):
super().__init__()
self.features = nn.Sequential(
# 1段目: 3チャンネル(RGB) -> 32チャンネル、高さ・幅は変えない
nn.Conv2d(3, 32, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(32), # チャンネルごとに正規化する
nn.ReLU(), # 非線形性を入れる
nn.MaxPool2d(kernel_size=2), # 高さ・幅を半分にする
# 2段目: 32チャンネル -> 64チャンネル
nn.Conv2d(32, 64, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2), # さらに高さ・幅を半分にする
# 3段目: 64チャンネル -> 128チャンネル。ここではpoolingしない
nn.Conv2d(64, 128, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(),
)
self.pool = nn.AdaptiveAvgPool2d((1, 1)) # 高さ・幅を1x1にそろえる
self.classifier = nn.Linear(128, num_classes) # 128次元をクラス数分のlogitへ
def forward(self, x):
x = self.features(x) # 畳み込み部分を通す
x = self.pool(x) # (N, 128, 1, 1) にする
x = torch.flatten(x, start_dim=1) # (N, 128) にする
x = self.classifier(x) # (N, num_classes) のlogitにする
return x
model = BanknoteCNN(num_classes=2)
x = torch.randn(8, 3, 224, 224) # (N, 3, 224, 224) の画像batchを想定
logits = model(x) # 各サンプルについて2クラス分のlogitが返る
print(logits.shape)
# torch.Size([8, 2])
出力は次の通りです。
torch.Size([8, 2])
形状の流れは次のようになります。
(8, 3, 224, 224)
-> Conv2d(3 -> 32), ReLU
-> (8, 32, 224, 224)
-> MaxPool2d(2)
-> (8, 32, 112, 112)
-> Conv2d(32 -> 64), ReLU
-> (8, 64, 112, 112)
-> MaxPool2d(2)
-> (8, 64, 56, 56)
-> Conv2d(64 -> 128), ReLU
-> (8, 128, 56, 56)
-> AdaptiveAvgPool2d((1, 1))
-> (8, 128, 1, 1)
-> Flatten
-> (8, 128)
-> Linear(128 -> 2)
-> (8, 2)
このモデルは、入力画像サイズが多少変わっても AdaptiveAvgPool2d((1, 1)) のおかげで分類器へ渡す特徴次元を 128 に保てます。
訓練時は、第3回のループと同じように CrossEntropyLoss を使えます。
criterion = nn.CrossEntropyLoss() # logitと正解ラベルから損失を計算する
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) # モデルの全パラメータを更新対象にする
images = torch.randn(8, 3, 224, 224) # ダミーの画像batch
labels = torch.randint(0, 2, (8,)) # ダミーの正解ラベル(0か1)
logits = model(images) # 順伝播でlogitを計算する
loss = criterion(logits, labels) # logitと正解ラベルから損失を計算する
optimizer.zero_grad(set_to_none=True) # 前回までの勾配をクリアする
loss.backward() # 逆伝播で各パラメータの勾配を計算する
optimizer.step() # 勾配を使ってパラメータを更新する
CrossEntropyLoss はlogitを受け取ります。モデルの最後でSoftmaxをかける必要はありません。
形状をHookで確認する
小さなCNNを組み立てて訓練の1ステップまで確認できたところで、次はモデル内部の各層でTensorの形がどう変化しているかをHookで確認する方法を見ていきます。CNNを作るときは、各層の入出力形状を実際に表示すると理解が早くなります。Hookを使うと、層を通るTensorの形を確認できます。
def print_shape(name):
def hook(module, inputs, output):
input_shape = tuple(inputs[0].shape) # そのモジュールに入ってきたTensorの形
output_shape = tuple(output.shape) # そのモジュールから出ていったTensorの形
print(f"{name}: {input_shape} -> {output_shape}")
# features.0: (8, 3, 224, 224) -> (8, 32, 224, 224)
# features.3: (8, 32, 224, 224) -> (8, 32, 112, 112)
# features.4: (8, 32, 112, 112) -> (8, 64, 112, 112)
# features.7: (8, 64, 112, 112) -> (8, 64, 56, 56)
# features.8: (8, 64, 56, 56) -> (8, 128, 56, 56)
# pool: (8, 128, 56, 56) -> (8, 128, 1, 1)
# classifier: (8, 128) -> (8, 2)
return hook # モジュールごとのhook関数を返す
model = BanknoteCNN(num_classes=2)
handles = [] # 後で外すためにhandleを覚えておく
for name, module in model.named_modules():
if isinstance(module, (nn.Conv2d, nn.MaxPool2d, nn.AdaptiveAvgPool2d, nn.Linear)):
handles.append(module.register_forward_hook(print_shape(name))) # forward後に呼ばれるhookを登録
x = torch.randn(8, 3, 224, 224)
_ = model(x) # forwardを実行すると、登録したhookが自動的に呼ばれる
for handle in handles:
handle.remove() # hookを外し、以後は呼ばれないようにする
出力は次のようになります。BanknoteCNN は features の中に Conv2d・MaxPool2d を複数含むため、named_modules() から見た名前は features.0 のように「コンテナ名+index」の形になります。
features.0: (8, 3, 224, 224) -> (8, 32, 224, 224)
features.3: (8, 32, 224, 224) -> (8, 32, 112, 112)
features.4: (8, 32, 112, 112) -> (8, 64, 112, 112)
features.7: (8, 64, 112, 112) -> (8, 64, 56, 56)
features.8: (8, 64, 56, 56) -> (8, 128, 56, 56)
pool: (8, 128, 56, 56) -> (8, 128, 1, 1)
classifier: (8, 128) -> (8, 2)
この出力を追うと、MaxPool2d で高さ・幅が半分になり、Conv2d でチャンネル数が変わり、最後に AdaptiveAvgPool2d と Linear で (8, 2) のlogitへまとまる流れが、実際のshapeの変化として確認できます。
Hookは登録すると、該当モジュールが呼ばれるたびに実行されます。確認が終わったら handle.remove() で外します。外し忘れると、同じHookが何度も呼ばれたり、中間出力を持ち続けたりする原因になります。
よくあるつまずきどころ
| つまずき | 原因 | 対処 |
|---|---|---|
子モジュールが parameters() に出ない |
普通のlistやdictに層を入れている |
ModuleList や ModuleDict を使う |
super().__init__() 関連のエラー |
Moduleの初期化前に層を代入している |
__init__() の先頭で super().__init__() を呼ぶ |
Conv2d のチャンネル不一致 |
入力の C と in_channels が違う |
入力shapeを確認する |
| NHWC画像をそのまま渡す | PyTorchのConv2dは通常NCHWを想定する |
x.permute(0, 3, 1, 2) で変換する |
Linear の入力次元が合わない |
Flatten後の特徴数を誤っている | Hookやダミー入力で形を確認する |
| batch次元が消える |
view(-1) で全部つぶしている |
torch.flatten(x, 1) を使う |
groups でエラー |
in_channels や out_channels が割り切れない |
groupsの制約を確認する |
eval() だけで勾配が止まると思っている |
モード切り替えとautogradは別 | 評価では torch.inference_mode() も使う |
| DropoutやBatchNormの評価結果が不安定 |
model.eval() を呼んでいない |
評価前に model.eval() を呼ぶ |
| 出力層にSoftmaxを入れている |
CrossEntropyLoss がlogitを想定している |
訓練時はlogitをそのまま渡す |
| 転置畳み込みで模様が出る | 出力位置ごとの重なりが不均一 | 補間 + Conv も検討する |
| Hookが何度も呼ばれる | 登録したhandleを外していない | 使用後に handle.remove() を呼ぶ |
モデルのエラーは、ほとんどの場合、shape、dtype、device、登録、モードのどれかに原因があります。層の名前だけを見るのではなく、Tensorが実際にどう流れているかを確認すると、切り分けが速くなります。
参考リンク
-
torch.nn: https://docs.pytorch.org/docs/stable/nn.html -
nn.Module: https://docs.pytorch.org/docs/stable/generated/torch.nn.Module.html -
nn.Sequential: https://docs.pytorch.org/docs/stable/generated/torch.nn.Sequential.html -
nn.ModuleList: https://docs.pytorch.org/docs/stable/generated/torch.nn.ModuleList.html -
nn.ModuleDict: https://docs.pytorch.org/docs/stable/generated/torch.nn.ModuleDict.html -
nn.Conv2d: https://docs.pytorch.org/docs/stable/generated/torch.nn.Conv2d.html -
nn.ConvTranspose2d: https://docs.pytorch.org/docs/stable/generated/torch.nn.ConvTranspose2d.html -
nn.MaxPool2d: https://docs.pytorch.org/docs/stable/generated/torch.nn.MaxPool2d.html -
nn.AvgPool2d: https://docs.pytorch.org/docs/stable/generated/torch.nn.AvgPool2d.html -
nn.MaxUnpool2d: https://docs.pytorch.org/docs/stable/generated/torch.nn.MaxUnpool2d.html -
nn.Linear: https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html - 活性化関数: https://docs.pytorch.org/docs/stable/nn.html#non-linear-activations-weighted-sum-nonlinearity
- torchvision models: https://docs.pytorch.org/vision/stable/models.html
おわりに
今回は、PyTorchでモデルを作るための中心である nn.Module と、CNNを構成する主要な層を整理しました。
nn.Module は、パラメータ、Buffer、子モジュール、保存、読み込み、デバイス移動、訓練・評価モードをまとめて管理します。Sequential は単純な順序処理に、ModuleList と ModuleDict は自分で制御したい構造に向いています。
畳み込みでは、in_channels、out_channels、kernel_size、stride、padding、dilation、groups がそれぞれTensorの形、接続、パラメータ数、計算量に影響します。式だけでなく、実際にダミーTensorを流してshapeを確認すると理解が定着します。
次回は、ここで作ったモデルをより実践的に訓練するために、損失関数、最適化手法、学習率スケジューリング、重み初期化、モデル保存の流れをさらに詳しく扱います。