Lesson 5 / 12

nn.Moduleでモデルを組む

このレッスンで学ぶこと

  • nn.Module を継承し、__init__forward でモデルを定義できる
  • nn.Linear の重みの形(out, in)と計算 x·Wᵀ+b を読める
  • パラメータ数を数えられる
  • CrossEntropyLoss が softmax を内包すること、MSELoss との使い分けが分かる

1. nn.Module とは

PyTorch のモデルは nn.Module を継承したクラスとして書きます。 __init__使う層を用意し、forward順伝播の流れを書く——この2つが基本構造です。

ここで紛らわしいのが nn.Modulenn.Linear の関係です。 どちらも nn(=torch.nn というモジュール)の中にある名前なので、その意味では両方とも「nn の属性」で合っています。 ただし両者は対等な部品ではありませんnn.Module「すべての層・モデルの親クラス(基底クラス)」で、 nn.Linear はその nn.Module継承して作られた一種——という上下(親子)の関係にあります。

💡 「〜は nn.Module の一種」という継承関係(is-a):

  • nn.Module親クラス。共通の土台で、パラメータの管理や forward 呼び出しの仕組みを持つ
  • nn.Linear / nn.Conv2d / nn.ReLU親を継承した子(PyTorch 既製の層)
  • 自分で書く class Net(nn.Module)同じく親を継承した子(層を組み合わせた自作モデル)

つまり「層も実は nn.Module」とは、nn.Linear も nn.Module を継承している=nn.Module の一種だ」という意味です。 だからモデルは「nn.Module(自作の Net)の中に nn.Modulenn.Linear などの層)を入れ子にしたもの」になります。 後で print(model) が層を一覧表示できるのも、この入れ子構造を nn.Module が把握しているおかげです。

L04 の autograd がこの上で自動的に効くので、私たちは順伝播だけ書けば、逆伝播は PyTorch がやってくれます

2. モデルを定義する

2層の全結合ネットワークを定義します。__init__ の先頭で super().__init__() を必ず呼ぶこと(これを忘れると層が登録されません)。 forward は「入力 x をどう流すか」を書くだけ。呼び出しは model(x) で、内部的に forward が走ります。

💡 super().__init__() は何をしている? これは親クラス nn.Module の初期化処理を呼ぶ命令です。 nn.Module は内部に「登録された層・パラメータを記録しておく箱」を持っており、その箱は super().__init__() が用意します。 この箱が準備できているからこそ、続く self.fc1 = nn.Linear(...) が単なる代入ではなく 「学習対象のパラメータを持つ層」として自動登録されます。 もし super().__init__() を書かないと、箱が無いまま代入することになり、 model.parameters() に層が現れない/エラーになります。 nn.Module を継承したら __init__ の先頭で super().__init__()」は定型のお約束として覚えてください。

model.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

class Net(nn.Module):
    def __init__(self):
        super().__init__()           # お約束。これを忘れると層が登録されない
        self.fc1 = nn.Linear(4, 8)  # 全結合 4 -> 8
        self.fc2 = nn.Linear(8, 3)  # 全結合 8 -> 3(3クラス分類)

    def forward(self, x):
        x = torch.relu(self.fc1(x))  # 1層目 + ReLU
        return self.fc2(x)            # 2層目(出力=ロジット)

model = Net()
x = torch.randn(5, 4)            # 入力 (N=5, 特徴4)
out = model(x)                 # forward が走る

print("out.shape:", out.shape)   # (5, 3)
print(model)
▼ 出力
out.shape: torch.Size([5, 3])
Net(
  (fc1): Linear(in_features=4, out_features=8, bias=True)
  (fc2): Linear(in_features=8, out_features=3, bias=True)
)

💡 出力の形=最後の層の out_features: 入力 (5, 4)fc1(5, 8)、ReLU で形そのまま、fc2(5, 3)3クラス分類なら最後の層の出力は3。この出力は softmax 前の生の値で、ロジット(logits)と呼びます。

活性化の3つの書き方(初出の補足): 上の forward で使った ReLU には書き方が3通りあり、どれも計算は同じです。 ① 関数版 torch.relu(x)(このレッスンで使用)/ ② torch.nn.functionalF.relu(x)import torch.nn.functional as F が必要)/ ③ クラス版 nn.ReLU() を作って nn.ReLU()(x) と呼ぶ(層として __init__ に置き nn.Sequential に並べたいとき向け)。 ReLU は学習パラメータを持たない処理なので、forward の中で関数版を直接呼ぶのが手軽です。

3. nn.Linear の中身(重みの形に注意)

nn.Linear(in, out) は内部に重み weight とバイアス bias を持ちます。 ここで重要な注意weight の形は (out, in) です(L02 で手書きした (in, out) と逆向き)。 PyTorch は内部で x · weightᵀ + bias転置して掛けるため、結果は同じく (N, out) になります。

linear_inside.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

fc1 = nn.Linear(4, 8)
print("weight.shape:", fc1.weight.shape)  # (out, in) = (8, 4)
print("bias.shape  :", fc1.bias.shape)    # (out,) = (8,)

# パラメータ数: fc1=4*8+8=40, fc2=8*3+3=27 → 合計67
class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(4, 8)
        self.fc2 = nn.Linear(8, 3)
    def forward(self, x):
        return self.fc2(torch.relu(self.fc1(x)))

model = Net()
total = sum(p.numel() for p in model.parameters())
print("パラメータ総数:", total)        # 67
▼ 出力
weight.shape: torch.Size([8, 4])
bias.shape  : torch.Size([8])
パラメータ総数: 67

💡 p.numel()model.parameters() numelnumber of elements の略で、テンソルの要素数(全成分の個数)を返します。 たとえば fc1.weight は形 (8, 4) なので numel()8×4=32fc1.bias(8,) なので 8 です。 一方 model.parameters()モデルが持つ全パラメータ(各層の weight と bias)を順に取り出すイテレータ。 だから sum(p.numel() for p in model.parameters()) は「全パラメータの要素数を合計する」=パラメータ総数を一発で求める定番の書き方です。

理論の確認: 1つの全結合層のパラメータ数は in×out + out(重み+バイアス)。 fc14×8+8=40fc28×3+3=27、合計 67パラメータ数を問う問題では、この式(バイアスを忘れない)を使います。

4. 損失関数:CrossEntropyLoss と MSELoss

出力と正解のズレを測るのが損失関数です。分類は nn.CrossEntropyLoss、回帰は nn.MSELoss が定番。 最重要の注意点:CrossEntropyLoss は内部で softmax(正確には log-softmax)を適用します。 つまりモデルの出力はロジット(softmax前)を渡すのが正解です。

loss.py(PyTorch・読むだけ)
import torch
import torch.nn as nn
torch.manual_seed(0)

# 分類:出力はロジット (N, クラス数)、正解はクラス番号 (N,)
logits = torch.randn(5, 3)            # 5サンプル・3クラスのロジット
target = torch.tensor([0, 2, 1, 0, 1])  # 各サンプルの正解クラス番号
ce = nn.CrossEntropyLoss()
print("CE loss:", round(ce(logits, target).item(), 4))

# 回帰:出力と正解はどちらも同じ形の実数
pred = torch.randn(5, 1)
y = torch.randn(5, 1)
mse = nn.MSELoss()
print("MSE loss:", round(mse(pred, y).item(), 4))
▼ 出力
CE loss: 0.9111
MSE loss: 2.0292

💡 CrossEntropyLoss が内部でしている3ステップ:

  • ① log-softmax:ロジットを「確率の対数」に変換する(softmax を取ってから log を取る計算を、数値的に安定な形でまとめて実行)
  • ② 正解クラスの値を取り出すtarget が指すクラスの対数確率だけを拾う
  • ③ 符号を反転して平均−(対数確率) をサンプル全体で平均する=負の対数尤度(NLL)

まとめると CrossEntropyLoss = log_softmax + NLLLosssoftmax は①の中に既に含まれているので、モデル側で softmax を付ける必要はありません。

この「内部で softmax 済み」を知らずに、モデルの最後へ自分で softmax を付けてから渡すと——softmax が2回かかります(二重適用)。 同じ logitstarget で比べると、損失の値が変わってしまうのが確認できます。

double_softmax.py(PyTorch・読むだけ/上の loss.py の続き)
# 正しい:ロジットをそのまま渡す
print(round(ce(logits, target).item(), 4))

# 誤り:自分で softmax してから渡す(softmax が二重にかかる)
probs = torch.softmax(logits, dim=1)
print(round(ce(probs, target).item(), 4))
▼ 出力
0.9111
0.9589

0.9111(正しい)に対し、二重適用すると 0.9589 とズレます。softmax を2回かけると確率分布がさらに平らに潰れ、 正解クラスと不正解クラスの差(モデルが学ぶべき手がかり)がぼやけて、勾配が弱まり学習が進みにくくなるのが原因です。

⚠ 実装でつまずきやすい点:softmax の二重適用

  • モデルの最後に softmax を付けない。ロジットのまま CrossEntropyLoss へ渡す(softmax は損失の内部で行われる)。
  • 正解(target)はクラス番号の整数(one-hot ではない)。形は (N,)、型は long。
  • 自分で log_softmax を書きたいときは、損失を nn.NLLLoss にする(CrossEntropyLoss = log_softmax + NLLLoss)。
  • 推論で「確率が見たい」ときは、損失計算とは別に torch.softmax(logits, dim=1) を使う(学習の損失には通さない)。

5. 練習問題

問題 1

出力の shape は?

次のモデルに (16, 10) の入力を通すと、出力 shape はどれですか。

quiz_1.py
class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(10, 32)
        self.fc2 = nn.Linear(32, 2)
    def forward(self, x):
        return self.fc2(torch.relu(self.fc1(x)))

out = Net()(torch.randn(16, 10))
  • A. (16, 32)
  • B. (16, 2)
  • C. (32, 2)
答えと解説を見る

正解:B. (16, 2)

バッチ16はそのまま。特徴は 10 → 32 →(ReLU)→ 2 と流れ、最後の層の出力2が残ります。

問題 2

CrossEntropyLoss に渡すのは?

分類で nn.CrossEntropyLoss を使うとき、モデルの出力はどうあるべきですか。

  • A. softmax を通した確率
  • B. softmax を通す前のロジット
  • C. one-hot ベクトル
答えと解説を見る

正解:B. softmax を通す前のロジット

CrossEntropyLoss は内部で log-softmax を行うため、ロジットをそのまま渡すのが正解。 自分で softmax を付けると二重適用になり、学習が崩れます。正解ラベルはクラス番号(整数)です。

問題 3

パラメータ数は?

nn.Linear(100, 10) 1層が持つパラメータ数(重み+バイアス)はいくつですか。

  • A. 1000
  • B. 1010
  • C. 110
答えと解説を見る

正解:B. 1010

重みは in×out = 100×10 = 1000、バイアスは out = 10。合計 1010バイアスを忘れないのがコツです。

6. まとめ

このレッスンのポイント

  • モデルは nn.Module を継承。__init__ で層を用意、forward で流れを書く
  • __init__ 冒頭の super().__init__() は必須
  • 呼び出しは model(x)(内部で forward が走る)
  • nn.Linear(in, out)weight は形 (out, in)、計算は x·Wᵀ+b
  • 1層のパラメータ数は in×out + out(バイアスを忘れない)
  • 分類は CrossEntropyLosssoftmaxを内包=ロジットを渡す/正解はクラス番号)、回帰は MSELoss

腕試し:出力を予想してみよう

このレッスンの仕上げです。次のコードを頭の中で実行して、出力を予想してみましょう (PyTorchはブラウザでは動かせないので、メモに書き出してから答えで確認するのがおすすめ)。

challenge.py(PyTorch・予想してみよう)
import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(4, 8)
        self.fc2 = nn.Linear(8, 5)   # 出力 3 → 5 に変更
    def forward(self, x):
        return self.fc2(torch.relu(self.fc1(x)))

model = Net()
out = model(torch.randn(10, 4))   # 入力 (10, 4)

print(out.shape)                                  # ① どんな形?
print(sum(p.numel() for p in model.parameters()))  # ② パラメータ総数は?
答えと解説を見る
torch.Size([10, 5])
85

① バッチ 10 はそのまま、特徴は 4 → 8 →(ReLU)→ 5 と流れ、最後の層の出力 5 が残るので torch.Size([10, 5])
fc1 = 4×8+8 = 40fc2 = 8×5+5 = 45、合計 85(バイアスを忘れない)。
おまけ:もし forward から torch.relu(...) を外すと、出力の形は (10, 5) のままですが、線形層だけが重なって実質1つの線形変換に潰れ、層を増やした意味が無くなります(非線形が表現力を生む)。

次は、このモデルを実際に学習させる学習ループ(最適化・正則化・DataLoader)を読み解きます。

完了するとコース一覧に進捗が記録されます