はじめに
最近、2D CNNの実装を複数の言語で比較する記事を書こうと思い立った。
その準備としてPyTorchを触り始めたのだが、
「あ、これ30年前にCで書いたやつだ」という感覚があった。
学生時代、ニューラルネットの本を片手に
バックプロパゲーション法をC言語でゴリゴリ実装した経験がある。
当時と今で、何がどう変わったのか。
エッセイとして書き残しておこうと思う。
当時のBP法、何が大変だったか
実装したのは大学時代、今から約30年前。
参考にしたのはニューラルネットの入門書で、
サンプルコードを見ながらC言語で写経+改造した。
大変だったのは主に3つ。
活性化関数の微分(シグモイド)
自動微分などという概念はなく、
活性化関数の微分を自分で計算してコードに書く必要があった。
例えばシグモイド関数:
sigmoid(x) = 1 / (1 + e^(-x))
その微分:
sigmoid'(x) = sigmoid(x) × (1 - sigmoid(x))
これを連鎖律で層をさかのぼりながら
何重にも適用してパラメータを更新していく。
数式とコードを行き来しながら書くのは、なかなか骨が折れた。
行列演算のforループ
今ならNumPyやPyTorchで一行で書ける行列積も、
当時はC言語で2次元配列をループで回していた。
// こんな感じのコードをひたすら書いていた
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
result[i][j] = 0;
for (int k = 0; k < inner; k++) {
result[i][j] += a[i][k] * b[k][j];
}
}
}
これが層の数だけ出てくる。
インデックスのi, j, kを取り違えるバグが頻発して、
デバッグに時間を溶かした記憶がある。
ハードウェアの制約
動作環境はEPSON PC-486SX 20MHz。
入力ノード5個・出力ノード3個程度の極小ネットワークでも、
学習完了まで約2時間かかった。
「とりあえず動かして、寝て、朝結果を確認する」という時代だった。
30年後、PyTorchを触ってみた
環境構築
まず環境構築から。
DevContainerを使えばワンクリックで環境が揃う。
当時はコンパイラの設定だけで一苦労だったのに、と思わずにはいられない。
.devcontainer/devcontainer.json:
{
"name": "PyTorch CPU",
"image": "mcr.microsoft.com/devcontainers/python:3.11",
"postCreateCommand": "pip install -r requirements.txt",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
]
}
}
}
requirements.txt:
# PyTorch CPU版
torch==2.3.0+cpu
torchvision==0.18.0+cpu
--extra-index-url https://download.pytorch.org/whl/cpu
コード
今回はMNIST(手書き数字)をCNNで学習してみた。
import time
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# -----------------------------------------------
# 1. データの準備
# 当時は手動でデータを読み込んでいたが、
# 今はダウンロードから前処理まで自動でやってくれる
# -----------------------------------------------
transform = transforms.Compose([
transforms.ToTensor(), # 画像を0〜1のテンソルに変換
transforms.Normalize((0.5,), (0.5,)) # 正規化(-1〜1に収める)
])
train_dataset = datasets.MNIST(
root='./data', train=True, download=True, transform=transform
)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_dataset = datasets.MNIST(
root='./data', train=False, download=True, transform=transform
)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
# -----------------------------------------------
# 2. CNNモデルの定義
# 当時は全結合+手書きの行列演算だったが、
# 今は「層を積み重ねる」だけで書ける
# -----------------------------------------------
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Sequential(
# 畳み込み層1:特徴を抽出する
nn.Conv2d(1, 16, kernel_size=3, padding=1),
nn.ReLU(), # 活性化関数(当時はシグモイドを手計算していた)
nn.MaxPool2d(2), # 28x28 → 14x14
# 畳み込み層2:より複雑な特徴を抽出
nn.Conv2d(16, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2), # 14x14 → 7x7
)
self.fc = nn.Sequential(
# 全結合層:当時のBP法に一番近い部分
nn.Flatten(),
nn.Linear(32 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10) # 出力:0〜9の10クラス
)
def forward(self, x):
x = self.conv(x)
x = self.fc(x)
return x
model = SimpleCNN()
# -----------------------------------------------
# 3. 損失関数とオプティマイザの設定
# -----------------------------------------------
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# -----------------------------------------------
# 4. 学習ループ
# 当時は手書きの勾配計算・パラメータ更新だったが、
# 今は3行で終わる
# -----------------------------------------------
print("学習開始")
start = time.time()
for epoch in range(5):
total_loss = 0
for images, labels in train_loader:
optimizer.zero_grad() # 勾配をリセット
outputs = model(images) # 順伝播
loss = criterion(outputs, labels)
loss.backward() # 逆伝播(勾配を自動計算!)
optimizer.step() # パラメータを更新
total_loss += loss.item()
print(f"Epoch {epoch+1}/5: Loss = {total_loss / len(train_loader):.4f}")
elapsed = time.time() - start
print(f"\n学習時間: {elapsed:.1f}秒")
print(f"(参考:486SX 20MHz時代は同等処理に約2時間かかっていた)")
# -----------------------------------------------
# 5. 評価
# -----------------------------------------------
model.eval()
correct = 0
total = 0
with torch.no_grad():
for images, labels in test_loader:
outputs = model(images)
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
print(f"\nテスト精度: {100 * correct / total:.2f}%")
実行結果
実行環境:Ryzen 5 7530U(CPUのみ)
学習時間 : 99秒
テスト精度 : 99.03%
正直、衝撃だった。
自動微分(autograd)
あの手計算していたシグモイドの微分も、
連鎖律の展開も、全部やってくれる。
loss.backward() の一行で。
行列演算
3重ループで書いていた行列積が:
result = torch.matmul(a, b)
これだけ。
コード量
当時は数百行書いていた処理が、数十行で済む。
しかも読みやすい。
何が変わって、何が変わっていないか
変わったもの:
| 項目 | 当時(486SX時代) | 現代(PyTorch) |
|---|---|---|
| 勾配計算 | 手書き | 自動微分(autograd) |
| 行列演算 | forループ | 1行 |
| コード量 | 数百行 | 数十行 |
| 学習時間 | 極小ネットワークで2時間 | CNNで99秒 |
| 精度 | 夢のような数字 | 99.03% |
変わっていないもの:
- 行列演算が本質であること
- シグモイドなどの活性化関数で非線形性を出すこと
- 連鎖律でパラメータを更新する仕組み
- 「ネットワーク設計 → 学習 → 評価」の流れ
ライブラリが隠蔽しているだけで、
PyTorchの裏側では30年前とやっていることは同じだ。
むしろ当時ゴリゴリ書いた経験があると、
「autogradが何を肩代わりしてくれているか」が
肌感覚でわかるという副産物がある。
手書き数字の認識を99%の精度で、CPUのみで99秒。
当時の極小ネットワークが2時間かかっていたことを思えば、隔世の感がある。
しかもこの精度、当時は夢のような数字だった。
ハードウェアの進化とは、恐ろしいものだ。
おわりに・次回予告
今回はエッセイとして書いたが、
次回からは実際にC++(libtorch)やONNX Runtimeでの
推論実装と比較をしていく予定。
学習はPythonで統一し、推論部分を各言語で比較する。
30年前はCで全部書いていたのに、
2026年にC++で書くとどうなるのか、自分でも楽しみだ。