GoogLeNet の概要と簡単な実験
¶ イントロダクション
GoogLeNet は、Google の論文 Going deeper with convolutions で初めて提案されました。この論文では Inception V1 / GoogLeNet アーキテクチャが紹介され、ILSVRC-2014 の分類部門で 1 位(Top-5 Error = 6.67%)を獲得しています。パラメータ数は約 680 万で、AlexNet より 9 倍少なく、VGG-16 より 20 倍少ないため、非常に軽量なモデルです。
本記事では GoogLeNet を簡単に紹介し、私が適当に(?)改造した GoogLeNet で行った小さな実験結果を共有します。
¶ GoogLeNet モデル概要
多くの場合、Max-pooling と Convolution をどのタイミングで使うべきかは直感的に分かりづらいです。そこで GoogLeNet は、異なるカーネルサイズの Convolution と Max-pooling を並列に適用し、それらの出力を Concatenate して 1 つの出力にまとめます。これを Inception モジュールと呼び、GoogLeNet は多数の Inception モジュールを積み重ねて構成されています。

上図は Inception モジュールの模式図です。GoogLeNet のもう 1 つの重要なアイデアは、Bottleneck の概念です。図の左が通常の Inception モジュールで、右が改造版です。改造版では 1×1 Convolution を導入しており、1×1 Conv によってパラメータ数を大幅に削減できます。これを Bottleneck と呼びます。

Bottleneck を使わない場合、MAC(Multiply–Accumulate Operation)の回数は $((28\times 28\times 5\times 5)\times 192)\times 32 ≃ 120$ になります。
一方で 1×1 Conv によって計算量を削減すると、MAC は第 1 層が $((28\times 28\times 1\times 1)\times 192)\times 16 ≃ 2.4M$、第 2 層が $((28\times 28\times 5\times 5)\times 16)\times 32 ≃ 10M $ となり、合計でおよそ $12.4M$ になります。MAC は概ね 10 倍程度減り、実際のパラメータ数も同様に約 10 倍減ることが分かります。

上図は GoogLeNet 全体のアーキテクチャです。大まかには 9 個の Inception モジュールから構成されています。

詳細な構成は上の表を参照してください。
¶ PyTorch による GoogLeNet 実装
コードはそれほど長くないので、そのまま全部貼ります。基本的には PyTorch の MNIST サンプルを土台にしつつ、pytorch-cifar100 プロジェクトにある GoogLeNet モジュールを利用しています。
以下のコードをコピーすれば、そのまま実行できるはずです。私の環境は Python3 + PyTorch 1.6 + CUDA 10.2 です。
from __future__ import print_function
import argparse
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
from torchvision import transforms
from torch.optim.lr_scheduler import StepLR
import numpy as np
class Inception(nn.Module):
def __init__(self, input_channels, n1x1, n3x3_reduce, n3x3, n5x5_reduce, n5x5, pool_proj):
super().__init__()
# 1x1conv branch
self.b1 = nn.Sequential(
nn.Conv2d(input_channels, n1x1, kernel_size=1),
nn.BatchNorm2d(n1x1),
nn.ReLU(inplace=True)
)
# 1x1conv -> 3x3conv branch
self.b2 = nn.Sequential(
nn.Conv2d(input_channels, n3x3_reduce, kernel_size=1),
nn.BatchNorm2d(n3x3_reduce),
nn.ReLU(inplace=True),
nn.Conv2d(n3x3_reduce, n3x3, kernel_size=3, padding=1),
nn.BatchNorm2d(n3x3),
nn.ReLU(inplace=True)
)
# 1x1conv -> 5x5conv branch
# we use 2 3x3 conv filters stacked instead
# of 1 5x5 filters to obtain the same receptive
# field with fewer parameters
self.b3 = nn.Sequential(
nn.Conv2d(input_channels, n5x5_reduce, kernel_size=1),
nn.BatchNorm2d(n5x5_reduce),
nn.ReLU(inplace=True),
nn.Conv2d(n5x5_reduce, n5x5, kernel_size=3, padding=1),
nn.BatchNorm2d(n5x5, n5x5),
nn.ReLU(inplace=True),
nn.Conv2d(n5x5, n5x5, kernel_size=3, padding=1),
nn.BatchNorm2d(n5x5),
nn.ReLU(inplace=True)
)
# 3x3pooling -> 1x1conv
# same conv
self.b4 = nn.Sequential(
nn.MaxPool2d(3, stride=1, padding=1),
nn.Conv2d(input_channels, pool_proj, kernel_size=1),
nn.BatchNorm2d(pool_proj),
nn.ReLU(inplace=True)
)
def forward(self,\times):
return torch.cat([self.b1(x), self.b2(x), self.b3(x), self.b4(x)], dim=1)
class GoogleNet(nn.Module):
def __init__(self, num_class=100):
super().__init__()
self.prelayer = nn.Sequential(
nn.Conv2d(3, 192, kernel_size=3, padding=1),
nn.BatchNorm2d(192),
nn.ReLU(inplace=True)
)
# although we only use 1 conv layer as prelayer,
# we still use name a3, b3.......
self.a3 = Inception(192, 64, 96, 128, 16, 32, 32)
self.b3 = Inception(256, 128, 128, 192, 32, 96, 64)
# """In general, an Inception network is a network consisting of
# modules of the above type stacked upon each other, with occasional
# max-pooling layers with stride 2 to halve the resolution of the
# grid"""
self.maxpool = nn.MaxPool2d(3, stride=2, padding=1)
self.a4 = Inception(480, 192, 96, 208, 16, 48, 64)
self.b4 = Inception(512, 160, 112, 224, 24, 64, 64)
self.c4 = Inception(512, 128, 128, 256, 24, 64, 64)
self.d4 = Inception(512, 112, 144, 288, 32, 64, 64)
self.e4 = Inception(528, 256, 160, 320, 32, 128, 128)
self.a5 = Inception(832, 256, 160, 320, 32, 128, 128)
self.b5 = Inception(832, 384, 192, 384, 48, 128, 128)
# input feature size: 8*8*1024
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.dropout = nn.Dropout2d(p=0.4)
self.linear = nn.Linear(1024, num_class)
def forward(self,\times):
output = self.prelayer(x)
output = self.a3(output)
output = self.b3(output)
output = self.maxpool(output)
output = self.a4(output)
output = self.b4(output)
output = self.c4(output)
output = self.d4(output)
output = self.e4(output)
output = self.maxpool(output)
output = self.a5(output)
output = self.b5(output)
# """It was found that a move from fully connected layers to
# average pooling improved the top-1 accuracy by about 0.6%,
# however the use of dropout remained essential even after
# removing the fully connected layers."""
output = self.avgpool(output)
output = self.dropout(output)
output = output.view(output.size()[0], -1)
output = self.linear(output)
return output
def train(args, model, device, train_loader, optimizer, epoch):
model.train()
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = F.cross_entropy(output, target)
loss.backward()
optimizer.step()
if batch_idx % args.log_interval == 0:
print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
epoch, batch_idx * len(data), len(train_loader.dataset),
100. * batch_idx / len(train_loader), loss.item()))
if args.dry_run:
break
def test(model, device, test_loader):
model.eval()
test_loss = 0
correct = 0
top5count = 0
with torch.no_grad():
for data, target in test_loader:
data, target = data.to(device), target.to(device)
output = model.forward(data)
v, result = output.topk(5, 1, True, True)
top5count += torch.eq(result, target.view(-1, 1)
).sum().int().item()
# sum up batch loss
test_loss += F.cross_entropy(output,
target, reduction='sum').item()
# get the index of the max log-probability
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
test_loss /= len(test_loader.dataset)
print('\nTest set: Average loss: {:.4f}, Top 1 Error: {}/{} ({:.2f}), Top 5 Error: {}/{} ({:.2f})\n'.format(
test_loss,
len(test_loader.dataset) - correct, len(test_loader.dataset),
1 - correct / len(test_loader.dataset),
len(test_loader.dataset) - top5count, len(test_loader.dataset),
1 - top5count / len(test_loader.dataset),
))
def main():
# Training settings
parser = argparse.ArgumentParser(description='PyTorch MNIST Example')
parser.add_argument('--batch-size', type=int, default=64, metavar='N',
help='input batch size for training (default: 64)')
parser.add_argument('--test-batch-size', type=int, default=10, metavar='N',
help='input batch size for testing (default: 10)')
parser.add_argument('--epochs', type=int, default=14, metavar='N',
help='number of epochs to train (default: 14)')
parser.add_argument('--lr', type=float, default=1.0, metavar='LR',
help='learning rate (default: 1.0)')
parser.add_argument('--gamma', type=float, default=0.7, metavar='M',
help='Learning rate step gamma (default: 0.7)')
parser.add_argument('--no-cuda', action='store_true', default=False,
help='disables CUDA training')
parser.add_argument('--dry-run', action='store_true', default=False,
help='quickly check a single pass')
parser.add_argument('--seed', type=int, default=1, metavar='S',
help='random seed (default: 1)')
parser.add_argument('--log-interval', type=int, default=10, metavar='N',
help='how many batches to wait before logging training status')
parser.add_argument('--save-model', action='store_true', default=False,
help='For Saving the current Model')
args = parser.parse_args()
use_cuda = not args.no_cuda and torch.cuda.is_available()
torch.manual_seed(args.seed)
device = torch.device("cuda:0")
train_kwargs = {'batch_size': args.batch_size}
test_kwargs = {'batch_size': args.test_batch_size}
if use_cuda:
cuda_kwargs = {'num_workers': 2,
'pin_memory': True,
'shuffle': True}
train_kwargs.update(cuda_kwargs)
test_kwargs.update(cuda_kwargs)
transform = transforms.Compose(
[transforms.RandomHorizontalFlip(p=0.5),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
trainset = torchvision.datasets.CIFAR100(root='./data', train=True,
download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, **train_kwargs)
testset = torchvision.datasets.CIFAR100(root='./data', train=False,
download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, **test_kwargs)
model = GoogleNet().to(device)
optimizer = optim.Adadelta(model.parameters(), lr=args.lr)
scheduler = StepLR(optimizer, step_size=1, gamma=args.gamma)
for epoch in range(1, args.epochs + 1):
train(args, model, device, trainloader, optimizer, epoch)
test(model, device, testloader)
scheduler.step()
if args.save_model:
torch.save(model.state_dict(), "mnist_cnn.pt")
model_parameters = filter(lambda p: p.requires_grad, model.parameters())
params = sum([np.prod(p.size()) for p in model_parameters])
print("Parameters:", params)
if __name__ == '__main__':
main()
¶ GoogLeNet の小さな実験
次に、GoogLeNet の性能を見てみるためにいくつか実験をしました。Bottleneck ありの GoogLeNet と、Bottleneck なしの Naïve GoogLeNet を用意しました。さらに、元の GoogLeNet に Inception を適当に 2 層追加したものを GoogLeNet Long、Inception 層を適当に減らしたものを GoogLeNet Short と呼ぶことにします。最も削減したのが GoogLeNet Short4 で、Inception は 2 層しか残っていません。パラメータ数を見ると、モデルの大きさを大まかに把握できます。
これらのモデルで CIFAR-100 と CIFAR-10 を学習し、Top-1 Error、Top-5 Error、Parameters、Time を記録しました。
CIFAR-100 での結果:
| Top 1 Error | Top 5 Error | Parameters | Time(14 epoch) | |
|---|---|---|---|---|
| GoogleNet Naïve | 0.36 | 0.09 | 65736148 | 52m38s |
| GoogleNet | 0.34 | 0.10 | 6258500 | 29m8s |
| GoogleNet Long | 0.35 | 0.10 | 9641924 | 36m41s |
| GoogleNet Short | 0.32 | 0.09 | 5271652 | 23m11s |
| GoogleNet Short2 | 0.32 | 0.09 | 3523556 | 16m29s |
| GoogleNet Short3 | 0.36 | 0.11 | 1985220 | 9m3s |
| GoogleNet Short4 | 0.44 | 0.15 | 1650084 | 8m56s |
CIFAR-10 での結果:
| Top 1 Error | Top 5 Error | Parameters | Time(14 epoch) | |
|---|---|---|---|---|
| GoogleNet Naïve | 0.15 | 0.01 | 65291098 | 52m51s |
| GoogleNet | 0.10 | 0.00 | 6166250 | 28m45s |
| GoogleNet Long | 0.11 | 0.00 | 9549674 | 40m12s |
| GoogleNet Short | 0.10 | 0.00 | 5179402 | 27m30s |
| GoogleNet Short2 | 0.10 | 0.00 | 3431306 | 31m57s |
| GoogleNet Short3 | 0.11 | 0.00 | 1892970 | 26m31s |
| GoogleNet Short4 | 0.15 | 0.01 | 1557834 | 25m30s |
まず、Naïve はパラメータ数が確かに 10 倍ほど多いものの、Accuracy の差はそれほど大きくありません。さらに Short4 を除けば、ほぼすべてのモデルの結果は同程度です。CIFAR-100 では Top-1 Error がだいたい 0.35、Top-5 Error が 0.10 前後で、CIFAR-10 では Top-1 Error が 0.10、Top-5 Error が 0.00(ほぼ 0)前後になっています。
これは CIFAR-100 / CIFAR-10 がそこまで複雑ではないため、画像認識においてはモデルの深さの影響が相対的に小さいからだと推測しています。また、深さ以外にもチャンネル数は精度に影響します。チャンネル数が十分大きければ、そこまで深いネットワークが必要ないのかもしれません。GoogLeNet に Max-Pooling 層や Dropout 層を適当に足してみる実験もしましたが、Error はほぼ同じでした。画像認識ではモデル自体の「許容度(頑健性)」がかなり高いのだと思います。
ただし以上の推論は簡単なデータセットでの話に限られます。複雑なベンチマークでは、数 % の精度差が非常に重要であり、小数点以下の僅かな違いの裏に膨大な工夫と労力が隠れています。それでも、GoogLeNet のすごいところは、パラメータを大幅に減らしながら精度がほとんど変わらない点です!