1. 概要
PyTorchを使って画像分類を実装していきたいと思います。
今回学習済みモデルを使わずに、自分で定義したモデルを使用して実装していきます。
PyTorch Lightningを使用して画像分類を実装した記事もあるので、良ければ参考にしながら見ていただければと思います。
2. モデル化の流れ
PyTorchは次の流れでモデル化していけば大きく間違えることはないかと思います。
- 準備
- データ準備
- 前処理
- Datasetの作成
- DataLoaderの作成
- ネットワークの作成
- ネットワークの定義
- 損失関数の定義
- 最適化手法の定義
- 学習と予測
- 学習の実行
- 予測の実行
では流れに沿って実装していきたいと思います。
3. 準備
3.1. データ準備
今回使用するデータは、犬のデータセットを使用して画像分類を試みます。
まずは下記のサイトにアクセスして画像データをダウンロードします。
http://vision.stanford.edu/aditya86/ImageNetDogs/
「Images」のリンクをクリックすれば、画像データのダウンロードが開始されます。
ダウンロードしたデータを解凍すると、120種類の犬種がディレクトリ別に格納されています。
すべてのデータを使うと計算リソースを必要とするので、次の5犬種に絞ってモデルを構築していきます。
・チワワ(Chihuahua)
・シーズー(Shih-Tzu)
・ボルゾイ(borzoi)
・パグ(pug)
・グレートデン(Great-Dane)
ディレクトリ構成は下記のようにしてあります。
├── Images # 画像データ │ ├── n02085620-Chihuahua │ ├── n02086240-Shih-Tzu │ ├── n02090622-borzoi │ ├── n02109047-Great_Dane │ └── n02110958-pug └── PyTorchによる画像分類の実装.ipynb # 実装スクリプト
最後に今回使用するライブラリです。
# ライブラリの読み込み import os from PIL import Image import torch import torch.utils.data as data import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torchvision import transforms import matplotlib.pyplot as plt %matplotlib inline
3.2. 前処理
Datasetにデータを渡す準備として、前処理をしていきます。
まずは、用意したデータセットを学習データと検証データに分割します。
格納されているデータ数は各犬種ごとに異なるので、それぞれ学習データを80%、検証データを20%にします。
そして、学習データ、検証データそれぞれのファイルパスを格納したリストを用意します。
def make_filepath_list(): """ 学習データ、検証データそれぞれのファイルへのパスを格納したリストを返す Returns ------- train_file_list: list 学習データファイルへのパスを格納したリスト valid_file_list: list 検証データファイルへのパスを格納したリスト """ train_file_list = [] valid_file_list = [] for top_dir in os.listdir('./Images/'): file_dir = os.path.join('./Images/', top_dir) file_list = os.listdir(file_dir) # 各犬種ごとに8割を学習データ、2割を検証データとする num_data = len(file_list) num_split = int(num_data * 0.8) train_file_list += [os.path.join('./Images', top_dir, file).replace('\\', '/') for file in file_list[:num_split]] valid_file_list += [os.path.join('./Images', top_dir, file).replace('\\', '/') for file in file_list[num_split:]] return train_file_list, valid_file_list # 画像データへのファイルパスを格納したリストを取得する train_file_list, valid_file_list = make_filepath_list() print('学習データ数 : ', len(train_file_list)) # 先頭3件だけ表示 print(train_file_list[:3]) print('検証データ数 : ', len(valid_file_list)) # 先頭3件だけ表示 print(valid_file_list[:3])
# Output 学習データ数 : 696 ['./Images/n02085620-Chihuahua/n02085620_10074.jpg', './Images/n02085620-Chihuahua/n02085620_10131.jpg', './Images/n02085620-Chihuahua/n02085620_10621.jpg'] 検証データ数 : 177 ['./Images/n02085620-Chihuahua/n02085620_588.jpg', './Images/n02085620-Chihuahua/n02085620_5927.jpg', './Images/n02085620-Chihuahua/n02085620_6295.jpg']
次に画像データに対する前処理(resizeなど)の処理を記述したクラスを作成します。
このクラスに画像データを通せば、指定した前処理を施したデータがえられます。
{train: ~, valid: ~}としている理由は、学習時と推論時で実施する前処理を変えるためです。
学習時にはモデルの性能を高めるために、データオーグメンテーション用の前処理を加えていますが、推論時にはデータオーグメンテーションする必要がないため、画像のサイズを整えるなどの処理だけにとどめています。
class ImageTransform(object): """ 入力画像の前処理クラス 画像のサイズをリサイズする Attributes ---------- resize: int リサイズ先の画像の大きさ mean: (R, G, B) 各色チャンネルの平均値 std: (R, G, B) 各色チャンネルの標準偏差 """ def __init__(self, resize, mean, std): self.data_trasnform = { 'train': transforms.Compose([ # データオーグメンテーション transforms.RandomHorizontalFlip(), # 画像をresize×resizeの大きさに統一する transforms.Resize((resize, resize)), # Tensor型に変換する transforms.ToTensor(), # 色情報の標準化をする transforms.Normalize(mean, std) ]), 'valid': transforms.Compose([ # 画像をresize×resizeの大きさに統一する transforms.Resize((resize, resize)), # Tensor型に変換する transforms.ToTensor(), # 色情報の標準化をする transforms.Normalize(mean, std) ]) } def __call__(self, img, phase='train'): return self.data_trasnform[phase](img) # 動作確認 img = Image.open('./Images/n02085620-Chihuahua/n02085620_199.jpg') # リサイズ先の画像サイズ resize = 300 # 今回は簡易的に(0.5, 0.5, 0.5)で標準化 mean = (0.5, 0.5, 0.5) std = (0.5, 0.5, 0.5) transform = ImageTransform(resize, mean, std) img_transformed = transform(img, 'train') plt.imshow(img) plt.show() plt.imshow(img_transformed.numpy().transpose((1, 2, 0))) plt.show()
実際にImageTransformクラスを実行してみると、画像がリサイズされ色も標準化されていることがわかります。
加えて、phaseパラメータをtrainに設定して何回か実行すると、画像が左右反転したデータも出力されることがわかります。
3.3. Datasetの作成
PyTorchのDatasetクラスを継承させたクラスを作ります。
処理を記述する箇所は「__len__」「__getitem__」の二つです。
「__len__」にはDatasetに含まれるデータ数を返す処理を記述します。
「__getitem__」にはindex番号を引数にとり、学習データとラベルデータを返す処理を記述します。
class DogDataset(data.Dataset): """ 犬種のDataseクラス。 PyTorchのDatasetクラスを継承させる。 Attrbutes --------- file_list: list 画像のファイルパスを格納したリスト classes: list 犬種のラベル名 transform: object 前処理クラスのインスタンス phase: 'train' or 'valid' 学習か検証化を設定 """ def __init__(self, file_list, classes, transform=None, phase='train'): self.file_list = file_list self.transform = transform self.classes = classes self.phase = phase def __len__(self): """ 画像の枚数を返す """ return len(self.file_list) def __getitem__(self, index): """ 前処理した画像データのTensor形式のデータとラベルを取得 """ # 指定したindexの画像を読み込む img_path = self.file_list[index] img = Image.open(img_path) # 画像の前処理を実施 img_transformed = self.transform(img, self.phase) # 画像ラベルをファイル名から抜き出す label = self.file_list[index].split('/')[2][10:] # ラベル名を数値に変換 label = self.classes.index(label) return img_transformed, label # 動作確認 # クラス名 dog_classes = [ 'Chihuahua', 'Shih-Tzu', 'borzoi', 'Great_Dane', 'pug' ] # リサイズ先の画像サイズ resize = 300 # 今回は簡易的に(0.5, 0.5, 0.5)で標準化 mean = (0.5, 0.5, 0.5) std = (0.5, 0.5, 0.5) # Datasetの作成 train_dataset = DogDataset( file_list=train_file_list, classes=dog_classes, transform=ImageTransform(resize, mean, std), phase='train' ) valid_dataset = DogDataset( file_list=valid_file_list, classes=dog_classes, transform=ImageTransform(resize, mean, std), phase='valid' ) index = 0 print(train_dataset.__getitem__(index)[0].size()) print(train_dataset.__getitem__(index)[1])
# Output torch.Size([3, 300, 300]) 0
3.4. DataLoaderの作成
バッチ処理を適用するためにDataLoaderを作成して、データセットをバッチ単位で取り出せるようにします。
のちの学習のために、学習用のDataLoaderと検証用のDataLoaderを辞書にまとめています。
# バッチサイズの指定 batch_size = 64 # DataLoaderを作成 train_dataloader = data.DataLoader( train_dataset, batch_size=batch_size, shuffle=True) valid_dataloader = data.DataLoader( valid_dataset, batch_size=32, shuffle=False) # 辞書にまとめる dataloaders_dict = { 'train': train_dataloader, 'valid': valid_dataloader } # 動作確認 # イテレータに変換 batch_iterator = iter(dataloaders_dict['train']) # 1番目の要素を取り出す inputs, labels = next(batch_iterator) print(inputs.size()) print(labels)
# Output torch.Size([32, 3, 300, 300]) tensor([2, 0, 2, 4, 4, 3, 4, 4, 3, 3, 1, 1, 3, 4, 4, 0, 1, 4, 0, 4, 3, 1, 1, 0, 4, 4, 3, 0, 1, 1, 1, 3])
4. ネットワークの作成
4.1. ネットワークの定義
ネットワークを定義するために、nn.Moduleを継承したクラスを作成します。
「__init__」の中に親クラスのコンストラクタの呼び出しと使用する層を記述して、「forward」の中に順伝播処理を記述していきます。
class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1_1 = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, padding=1) self.conv1_2 = nn.Conv2d(in_channels=64, out_channels=64, kernel_size=3, padding=1) self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) self.conv2_1 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, padding=1) self.conv2_2 = nn.Conv2d(in_channels=128, out_channels=128, kernel_size=3, padding=1) self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(in_features=128 * 75 * 75, out_features=128) self.fc2 = nn.Linear(in_features=128, out_features=5) def forward(self, x): x = F.relu(self.conv1_1(x)) x = F.relu(self.conv1_2(x)) x = self.pool1(x) x = F.relu(self.conv2_1(x)) x = F.relu(self.conv2_2(x)) x = self.pool2(x) x = x.view(-1, 128 * 75 * 75) x = self.fc1(x) x = F.relu(x) x = self.fc2(x) x = F.softmax(x, dim=1) return x net = Net() print(net)
# Output Net( (conv1_1): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (conv1_2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (pool1): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (conv2_1): Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (conv2_2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1)) (pool2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False) (fc1): Linear(in_features=720000, out_features=128, bias=True) (fc2): Linear(in_features=128, out_features=5, bias=True) )
4.2. 損失関数の定義
損失関数を定義します。
今回はクロスエントロピー誤差を使用します。
criterion = nn.CrossEntropyLoss()
4.3. 最適化手法の定義
最適化手法を定義します。
最適化していくパラメータとハイパーパラメータを指定していきます。
最適化していくパラメータはnet.parameters()で渡します。
optimizer = optim.SGD(net.parameters(), lr=0.01)
5. 学習と予測
5.1. 学習の実行
PyTorchでは学習時推論時でネットワークのモードを分ける必要があります。
「net.train()」「net.eval()」でそれぞれのモードを分ける処理を書いています。
# エポック数 num_epochs = 30 for epoch in range(num_epochs): print('Epoch {}/{}'.format(epoch+1, num_epochs)) print('-------------') for phase in ['train', 'val']: if phase == 'train': # モデルを訓練モードに設定 net.train() else: # モデルを推論モードに設定 net.eval() # 損失和 epoch_loss = 0.0 # 正解数 epoch_corrects = 0 # DataLoaderからデータをバッチごとに取り出す for inputs, labels in dataloaders_dict[phase]: # optimizerの初期化 optimizer.zero_grad() # 学習時のみ勾配を計算させる設定にする with torch.set_grad_enabled(phase == 'train'): outputs = net(inputs) # 損失を計算 loss = criterion(outputs, labels) # ラベルを予測 _, preds = torch.max(outputs, 1) # 訓練時はバックプロパゲーション if phase == 'train': # 逆伝搬の計算 loss.backward() # パラメータの更新 optimizer.step() # イテレーション結果の計算 # lossの合計を更新 # PyTorchの仕様上各バッチ内での平均のlossが計算される。 # データ数を掛けることで平均から合計に変換をしている。 # 損失和は「全データの損失/データ数」で計算されるため、 # 平均のままだと損失和を求めることができないため。 epoch_loss += loss.item() * inputs.size(0) # 正解数の合計を更新 epoch_corrects += torch.sum(preds == labels.data) # epochごとのlossと正解率を表示 epoch_loss = epoch_loss / len(dataloaders_dict[phase].dataset) epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset) print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))
# Output Epoch 1/30 ------------- train Loss: 1.6087 Acc: 0.2500 valid Loss: 1.6081 Acc: 0.2429 Epoch 2/30 ------------- train Loss: 1.6074 Acc: 0.2486 valid Loss: 1.6070 Acc: 0.2429 Epoch 3/30 ------------- train Loss: 1.6061 Acc: 0.2457 valid Loss: 1.6060 Acc: 0.2429 ~~~~~~ Epoch 27/30 ------------- train Loss: 1.5347 Acc: 0.3563 valid Loss: 1.5531 Acc: 0.2881 Epoch 28/30 ------------- train Loss: 1.5275 Acc: 0.3606 valid Loss: 1.5471 Acc: 0.3559 Epoch 29/30 ------------- train Loss: 1.5136 Acc: 0.3793 valid Loss: 1.5422 Acc: 0.3277 Epoch 30/30 ------------- train Loss: 1.5047 Acc: 0.3894 valid Loss: 1.5753 Acc: 0.2712
以上でPyTorchでのモデル化の流れが完了です。
5.2. 予測の実行
あとは、学習したモデルで推論してやれば予測結果を取得できます。
# 予測用のダミーデータ x = torch.randn([1, 3, 300, 300]) preds = net(x)
6. 所感
今回はパラメータチューニング、転移学習、ファインチューニングなどは実施せずに画像分類モデルを作成しました。
予測精度の良いモデルは得られませんでした。
今回は分類モデルを構築できたということで、良しとさせていただきます。
7. 全体のコード
最後に全体のコードをまとめて載せておきます。
# ライブラリの読み込み import os from PIL import Image import torch import torch.utils.data as data import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torchvision import transforms import matplotlib.pyplot as plt %matplotlib inline # 学習データ、検証データへの分割 def make_filepath_list(): """ 学習データ、検証データそれぞれのファイルへのパスを格納したリストを返す Returns ------- train_file_list: list 学習データファイルへのパスを格納したリスト valid_file_list: list 検証データファイルへのパスを格納したリスト """ train_file_list = [] valid_file_list = [] for top_dir in os.listdir('./Images/'): file_dir = os.path.join('./Images/', top_dir) file_list = os.listdir(file_dir) # 各犬種ごとに8割を学習データ、2割を検証データとする num_data = len(file_list) num_split = int(num_data * 0.8) train_file_list += [os.path.join('./Images', top_dir, file).replace('\\', '/') for file in file_list[:num_split]] valid_file_list += [os.path.join('./Images', top_dir, file).replace('\\', '/') for file in file_list[num_split:]] return train_file_list, valid_file_list # 前処理クラス class ImageTransform(object): """ 入力画像の前処理クラス 画像のサイズをリサイズする Attributes ---------- resize: int リサイズ先の画像の大きさ mean: (R, G, B) 各色チャンネルの平均値 std: (R, G, B) 各色チャンネルの標準偏差 """ def __init__(self, resize, mean, std): self.data_trasnform = { 'train': transforms.Compose([ # データオーグメンテーション transforms.RandomHorizontalFlip(), # 画像をresize×resizeの大きさに統一する transforms.Resize((resize, resize)), # Tensor型に変換する transforms.ToTensor(), # 色情報の標準化をする transforms.Normalize(mean, std) ]), 'valid': transforms.Compose([ # 画像をresize×resizeの大きさに統一する transforms.Resize((resize, resize)), # Tensor型に変換する transforms.ToTensor(), # 色情報の標準化をする transforms.Normalize(mean, std) ]) } def __call__(self, img, phase='train'): return self.data_trasnform[phase](img) # Datasetクラス class DogDataset(data.Dataset): """ 犬種のDataseクラス。 PyTorchのDatasetクラスを継承させる。 Attrbutes --------- file_list: list 画像のファイルパスを格納したリスト classes: list 犬種のラベル名 transform: object 前処理クラスのインスタンス phase: 'train' or 'valid' 学習か検証化を設定 """ def __init__(self, file_list, classes, transform=None, phase='train'): self.file_list = file_list self.transform = transform self.classes = classes self.phase = phase def __len__(self): """ 画像の枚数を返す """ return len(self.file_list) def __getitem__(self, index): """ 前処理した画像データのTensor形式のデータとラベルを取得 """ # 指定したindexの画像を読み込む img_path = self.file_list[index] img = Image.open(img_path) # 画像の前処理を実施 img_transformed = self.transform(img, self.phase) # 画像ラベルをファイル名から抜き出す label = self.file_list[index].split('/')[2][10:] # ラベル名を数値に変換 label = self.classes.index(label) return img_transformed, label # ネットワークの定義 class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1_1 = nn.Conv2d(in_channels=3, out_channels=64, kernel_size=3, padding=1) self.conv1_2 = nn.Conv2d(in_channels=64, out_channels=64, kernel_size=3, padding=1) self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) self.conv2_1 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, padding=1) self.conv2_2 = nn.Conv2d(in_channels=128, out_channels=128, kernel_size=3, padding=1) self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) self.fc1 = nn.Linear(in_features=128 * 75 * 75, out_features=128) self.fc2 = nn.Linear(in_features=128, out_features=5) def forward(self, x): x = F.relu(self.conv1_1(x)) x = F.relu(self.conv1_2(x)) x = self.pool1(x) x = F.relu(self.conv2_1(x)) x = F.relu(self.conv2_2(x)) x = self.pool2(x) x = x.view(-1, 128 * 75 * 75) x = self.fc1(x) x = F.relu(x) x = self.fc2(x) x = F.softmax(x, dim=1) return x # 各種パラメータの用意 # クラス名 dog_classes = [ 'Chihuahua', 'Shih-Tzu', 'borzoi', 'Great_Dane', 'pug' ] # リサイズ先の画像サイズ resize = 300 # 今回は簡易的に(0.5, 0.5, 0.5)で標準化 mean = (0.5, 0.5, 0.5) std = (0.5, 0.5, 0.5) # バッチサイズの指定 batch_size = 64 # エポック数 num_epochs = 30 # 2. 前処理 # 学習データ、検証データのファイルパスを格納したリストを取得する train_file_list, valid_file_list = make_filepath_list() # 3. Datasetの作成 train_dataset = DogDataset( file_list=train_file_list, classes=dog_classes, transform=ImageTransform(resize, mean, std), phase='train' ) valid_dataset = DogDataset( file_list=valid_file_list, classes=dog_classes, transform=ImageTransform(resize, mean, std), phase='valid' ) # 4. DataLoaderの作成 train_dataloader = data.DataLoader( train_dataset, batch_size=batch_size, shuffle=True) valid_dataloader = data.DataLoader( valid_dataset, batch_size=32, shuffle=False) # 辞書にまとめる dataloaders_dict = { 'train': train_dataloader, 'valid': valid_dataloader } # 5. ネットワークの定義 net = Net() # 6. 損失関数の定義 criterion = nn.CrossEntropyLoss() # 7. 最適化手法の定義 optimizer = optim.SGD(net.parameters(), lr=0.01) # 8. 学習・検証 for epoch in range(num_epochs): print('Epoch {}/{}'.format(epoch + 1, num_epochs)) print('-------------') for phase in ['train', 'valid']: if phase == 'train': # 学習モードに設定 net.train() else: # 訓練モードに設定 net.eval() # epochの損失和 epoch_loss = 0.0 # epochの正解数 epoch_corrects = 0 for inputs, labels in dataloaders_dict[phase]: # optimizerを初期化 optimizer.zero_grad() # 学習時のみ勾配を計算させる設定にする with torch.set_grad_enabled(phase == 'train'): outputs = net(inputs) # 損失を計算 loss = criterion(outputs, labels) # ラベルを予測 _, preds = torch.max(outputs, 1) # 訓練時は逆伝搬の計算 if phase == 'train': # 逆伝搬の計算 loss.backward() # パラメータ更新 optimizer.step() # イテレーション結果の計算 # lossの合計を更新 # PyTorchの仕様上各バッチ内での平均のlossが計算される。 # データ数を掛けることで平均から合計に変換をしている。 # 損失和は「全データの損失/データ数」で計算されるため、 # 平均のままだと損失和を求めることができないため。 epoch_loss += loss.item() * inputs.size(0) # 正解数の合計を更新 epoch_corrects += torch.sum(preds == labels.data) # epochごとのlossと正解率を表示 epoch_loss = epoch_loss / len(dataloaders_dict[phase].dataset) epoch_acc = epoch_corrects.double() / len(dataloaders_dict[phase].dataset) print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))