FCNを用いたモデルを実装してみる
あくまで初心者向けの解説記事+自分用の備忘録となっています。
ところどころ実装にあたって必要のない処理が含まれていることをご承知ください
前提
-
使用データ:Pascal VOC2011 (21クラス)
-
タスク:Semantic Segmentation(画像ごとに21クラス分類)
-
前処理
- 画像:224×224にリサイズし、ToTensorで0~1のfloat
- ラベル:224×224にリサイズ、PILToTensor → long → squeeze
- ラベルは0~20の整数クラスマップとする(One-hotしない)
-
モデル
- FCN(Fully Convolutional Network)
- BackboneはResNet
- 出力形式は(N,21,224,224)のロジット
- softmaxは適用しない
⇒CrossEntropyLoss内部で自動的にsoftmaxを取るため
-
損失関数
- nn.CrossEntropyLoss()を使用
- 入力形式:
- 予測:(N,21,H,W)
- 正解:(N,H,W)の整数ラベル
- そのため教師ラベルはone-hotにせず、整数のクラスマップを使用する
-
Optimizer(最適化手法)
- Adam Optimizerを使用
- lr = 1e-4
- weight_decay = 1e-4
-
学習手順
- forward
- loss(CrossEntropyLoss)を計算
- backward(差逆伝播)
- optimizer.step(パラメータ更新)
評価指標はmean IoU(mIoU)とする
- 推論処理
- モデル出力(21,224,224)に対して
argmax(dim=1)を行い
各ピクセルで最もスコアの高いクラスを選択 - 得られる具体的な予測は(224,224)のセグメンテーションマスク
- これを正解ラベルと比較してmIoUを算出する
- モデル出力(21,224,224)に対して
FCNとは?
Fully Convolutional Network(完全畳み込みネットワーク)という手法で、CNN(畳み込みニューラルネットワーク)で画像認識の最終段階で行う「全結合」を廃止し、すべての層を「畳み込み層」で構成する。
これにより、空間的な情報を維持したまま画像を処理でき、ピクセルごとの分類が可能になる。また、入力画像を任意のサイズにできるというメリットもある
実際のコードで実装していく
環境はgoogle colabで行う
事前準備
データは事前に作業ディレクトリに保存しておく
ドライブのマウントと作業ディレクトリを指定
from google colab import drive
drive.mount('/content/drive')
work_dir = '<好きな作業ディレクトリを指定>'
データの読み込み
ライブラリをインポートし、データ(画像)を定義
import random
import numpy as np
import pandas as pd
import torch
from torchvision import transforms
from tqdm import tqdm_notebook as tqdm
from PIL import Image
from sklearn.model_selection import train_test_split
#学習データ
x_train = np.load(work_dir + '/Lecture06/data/x_train.npy', allow_pickle=True)
y_train = np.load(work_dir + '/Lecture06/data/y_train.npy', allow_pickle=True)
#テストデータ
x_test = np.load(work_dir + '/Lecture06/data/x_test.npy', allow_pickle=True)
データの形を知る
y_train(正解ラベル)の形を確認してみる
for i in range(5):
print(type(y_train)) # データ全体の型を調べる
print(type(y_train[i])) # 1つの型を調べる
print(y_train.size) #検証用
print(y_train.shape) #検証用
print(y_train[i].size) #検証用
print(y_train[i].shape) # データの配列の形を見る
print(np.unique(y_train[i])) # 配列になんの数字が格納されているか?
print("\n")
実行結果
<class 'numpy.ndarray'>
<class 'numpy.ndarray'>
(1923,)
(375, 500)#サイズは375px×500px=187500px
[ 0 14 15 255]
<class 'numpy.ndarray'>
<class 'numpy.ndarray'>
(1923,)
(375, 500)
[ 0 16 255]
<class 'numpy.ndarray'>
<class 'numpy.ndarray'>
(1923,)
(500, 400)#サイズは500px×400px=200000px
[ 0 19 255]
<class 'numpy.ndarray'>
<class 'numpy.ndarray'>
1923
(1923,)
(375, 500)
[ 0 16 18 20 255]
<class 'numpy.ndarray'>
<class 'numpy.ndarray'>
1923
(1923,)
187500
(500, 375)
[ 0 6 7 15 255]
実行結果から分かったこと
-
データ全体の形は1次元配列、1つのデータの形は2次元配列のNumpy配列
つまりY_trainは2次元配列のリストである -
どうやら画像ごとにサイズが異なっている様子
-
このラベルは何を表している?
⇒voc_classes = {
0: "background",
1: "aeroplane",
2: "bicycle",
3: "bird",
4: "boat",
5: "bottle",
6: "bus",
7: "car",
8: "cat",
9: "chair",
10: "cow",
11: "diningtable",
12: "dog",
13:"horse",
14: "motorbike",
15: "person",
16: "potted plant",
17: "sheep",
18: "sofa",
19: "train",
20: "tv/monitor"
}
という対応表みたい
実際に見てみる
import matplotlib.pyplot as plt
import numpy as np
# pltモジュールのメソッド
"""
figure():新しい図(figure)を作る
sumplot():figure内にサブプロットを作成
imshow():画像や2D配列を表示(セグメンテーションマスクにも使える)
title():図やサブプロットにタイトルをつける
axis():軸の表示設定(offで非表示など)
"""
# 入力画像(RGB)
plt.figure(figsize=(8,4))
plt.subplot(1,2,1)
plt.imshow(x_train[3].astype('uint8'))
plt.title("Input Image")
plt.axis('off')
# ラベル画像
label = y_train[3]# [3]
# 255 を背景として扱う
label_vis = label.copy()
label_vis[label_vis == 255] = 0
plt.subplot(1,2,2)
plt.imshow(label_vis, cmap='tab20') # 21クラス用カラーマップ
plt.title("Segmentation Mask")
plt.axis('off')
plt.show()
np.unique(y_train[3])の出力が[ 0 16 18 20 255]なので、対応表で見てみると、potted plant, sofa, tv/monitorとなっている
DataLoaderに渡すクラスの定義
何をするクラス?
⇒画像のリサイズ
- モデルへの入力画像のサイズを揃えないと学習できない為リサイズする
理由:
- ニューラルネットは固定サイズ入力が前提。
ResNet など多くの CNN モデルは 入力サイズが固定(224×224 など) で設計されている。 - ミニバッチを作る際に、(H,W)が異なるとテンソルの形が揃わない。
- 大きすぎる画像はGPU負荷が大きい。
1280×720のまま学習すると224×224の約16倍。
⇒GPU メモリがすぐ枯れる。
# 実装の都合上,コンストラクタ内で画像をリサイズ
class train_dataset(torch.utils.data.Dataset):
#__init__初期化関数
def __init__(self, x_train, y_train, transform=None, target_transform=None):
self.x_train = x_train # 元の numpy 配列を保持
self.y_train = y_train
self.transform = transform
self.target_transform = target_transform
#dataloaderが必要とするメソッド
def __len__(self):
return len(self.x_train)
#dataloaderが必要とするメソッド
def __getitem__(self, idx):
# numpy -> PIL
img = Image.fromarray(np.uint8(self.x_train[idx]))
label = Image.fromarray(np.uint8(self.y_train[idx]))
# リサイズ
img = transforms.Resize((224,224), interpolation=Image.BILINEAR)(img)
label = transforms.Resize((224,224), interpolation=Image.NEAREST)(label) # ラベルは補間方法に注意
# transform 適用
if self.transform:
img = self.transform(img)
if self.target_transform:
label = self.target_transform(label)
return img, label
torch.units.data.Datasetという親クラスからtrain datasetという子クラスにインスタンスを継承してオーバーライド(書き換え)している
なぜ継承する必要があるのか
⇒後の処理にDataLoader(画像を読み取るもの)があるのだが、__len__と__getitem__がないと受け付けてくれないから
このクラス(train_dataset)について
-
init():初期化関数について
初期化関数はこのクラスが呼ばれたときに1度だけ実行される処理。
主に変数の定義に用いられる -
getitem():オーバーライドした特殊メソッドについて
- Numpy配列からPIL画像へ
- x_train, y_trainをuint8へ変換し、PIL画像にする
- 画像のサイズを(224×224)へリサイズ
- img:リサイズする際、バイリニア補間で値を補間
- label:リサイズする際、最近傍補間で値を補間
- もし、transformやtarget_transformが定義されているなら、定義された関数を適用する。定義されていないなら、何もしない
- Numpy配列からPIL画像へ
同様にテストデータにも適用
class test_dataset(torch.utils.data.Dataset):
def __init__(self, x_test, transform=None):
self.x_test = x_test # 元データはそのまま保持
self.transform = transform if transform else transforms.ToTensor()
def __len__(self):
return len(self.x_test)
def __getitem__(self, idx):
img = Image.fromarray(np.uint8(self.x_test[idx]))
img = transforms.Resize((224,224), interpolation=Image.BILINEAR)(img)
if self.transform:
img = self.transform(img)
return img
実際にデータセットに以上の処理を適用
trainval_data = train_dataset(x_train, y_train)
test_data = test_dataset(x_test)
変換できたのか確認してみる
print(trainval_data.x_train[0].size)
結果
(224, 224)
無事にリサイズできていた
FCNの実装
GPU or CPUの定義
import torch
from torch import nn
from torch import optim
import torch.nn.functional as F
import torchvision
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(device)
gpuが使えるならcudaを, 使えないならcpuを使う
GPUでしかやったことないので、CPUでできるかは不明
シード(乱数)の固定
def fix_seed(seed=1234):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
fix_seed(seed=42)
- なぜ固定するのか?
⇒再現性を確保するため
データセットの分割
val_size = 100
train_data, val_data = torch.utils.data.random_split(trainval_data, [len(trainval_data)-val_size, val_size])
学習データ(trainaval_data)を訓練データ(train_data)と検証データ(val_data)に
分割する。
検証データ100枚だけ残し、残りを訓練用に使う
trainval_dataやtest_dataのインスタンス(transform)を上書きする
image_transform = transforms.ToTensor()
target_transform = transforms.Compose([
transforms.PILToTensor(),
transforms.Lambda(lambda x: x.long()),
transforms.Lambda(lambda x: x.squeeze(0)),
])
trainval_data.transform = image_transform
trainval_data.target_transform = target_transform
test_data.transform = image_transform
なぜこんなに回りくどいの?最初からtransformを定義すればよかったじゃん
⇒後で処理を追加するときに、Datasetのコードを書き換えずに済む。汎用クラスにできる。
さて、ちょっと複雑になってきたので、x_trainとy_trainの処理の流れを整理する
x_train:
- getitemが呼ばれる
- 画像配列(H,W,C)ををuint8に変換
画像として扱うため0~255の整数にする。 - Image.fromarrayにより画像配列からPIL画像に変換
- (224×224)にリサイズする際、バイリニア補間
- trainval_data.transform = image_transformが適用される
image_transform内の処理であるToTensor()によって以下の状態になる
- PIL画像 → Tensor
- dtype:float32
- 値:0~1
- shape:(C,H,W)
ToTensor内で起きていること。
- PIL画像を「0~255の配列」として読み取る
(形状:H,W,C) - チャンネル順を変更する
配列の形状を(C,H,W)に並び替える - 画素値を0~1に正規化しTensorへ
Tensor = array / 255.0 → dtypeはfloat32へ
y_train:
1~3までの処理はx_trainと同様
4. リサイズする際、最近傍補間する
5. test_data.transform = target_transformが適用される
target_transform内の処理で以下の状態になる
- PIL画像 → Tensor
- dtype:long
- 値:0~20
- shape:(H,W)
DataLoaderの定義
DataLoaderはDatasetからミニバッチサイズ単位でデータを取りだし、学習や推論に使いやすくするラッパー
batch_size=16
# dataloaderの定義
dataloader_train = torch.utils.data.DataLoader(
train_data,
batch_size=batch_size,
shuffle=True
)
dataloader_valid = torch.utils.data.DataLoader(
val_data,
batch_size=batch_size,
shuffle=False
)
dataloader_test = torch.utils.data.DataLoader(
test_data,
batch_size=batch_size,
shuffle=False
)
- (train, val)_data:train_datasetのインスタンス(__getitem__や__len__が定義されている)
- test_data:test_datasetのインスタンス
- batch_size=batch_size:一度に取り出すデータ数(ミニバッチサイズ)
- shuffle=:データを毎エポックごとにランダムに並び替える
- True:学習が安定
- False:評価が安定
FCNの定義
#クラス定義と初期化
class FCN(nn.Module):
def __init__(self, backbone, num_classes=21):
super(FCN, self).__init__()
# backbone
self.backbone = backbone
# convolution
self.FCNhead = nn.Sequential(nn.Conv2d(2048, 256, kernel_size=3, padding=2, dilation=2, bias=False),
nn.BatchNorm2d(256),
nn.ReLU(inplace=True),
nn.Dropout(0.1),
nn.Conv2d(256, num_classes, kernel_size=1))
def forward(self, x):
input_shape = x.shape[-2:] # shape: (224, 224)
x = self.backbone(x)
x = self.FCNhead(x)
x = F.interpolate(
x,
size=input_shape,
mode='bilinear',
align_corners=False
)
return x
backbone = torchvision.models.resnet50(pretrained=torchvision.models.ResNet50_Weights.DEFAULT)
backbone = nn.Sequential(*list(backbone.children())[:-2]) # GAP層とFC層を外す
- self.backbone
- ResNet50などの事前学習済みネットワーク
- 最後の全結合層(GAP,FC)は外して、特徴マップだけを抽出
- ResNetによるデータの形状変化
入力: (3, 224, 224)#入力層
↓ Conv1 (7×7 s=2)
(64, 112, 112)#入力層
↓ MaxPool (3×3 s=2)
(64, 56, 56)#入力層, 1層目は中間層=入力層
↓ Layer1 (Bottleneck × 3)# 中間層において、C_l=C_(l-1)×4
(256, 56, 56)#入力層, 2層目以降は中間層=入力層/2
↓ Layer2 (Bottleneck × 4)
(512, 28, 28)#入力層
↓ Layer3 (Bottleneck × 6)
(1024, 14, 14)#入力層
↓ Layer4 (Bottleneck × 3)
(2048, 7, 7)に圧縮 #出力層
- self.FCNhead
- 1×1または3×3Convで特徴マップをクラス数に変換
- BatchNorm → ReLU → Dropout → 最終Convの順で構成
FCNheadはバックボーンから出てきた特徴マップ(2048,7,7)をクラスごとのスコアに変換する。
nn.Conv2d(2048, 256, kernel_size=3, padding=2, dilation=2, bias=False)
- 入力チャンネル:2048 → ResNet50の最後の特徴マップのチャンネル数
- 出力チャンネル:256 → 256個の特徴マップに圧縮
- kernel_size=3:3×3の畳み込み
- padding=2, dilation=2:ダイレーション畳み込み(間隔をあけて畳み込み)
- 受容野(receptive field)を広げる効果
- bias=False:バイアス項を使わない
- 役割:高次元特徴(2048)を圧縮して、局所情報を保持しつつ後続の処理に渡す
nn.BatchNorm2d(256)
- BatchNorm:各チャネルごとに正規化
- 効果:学習を安定させる、勾配消失を防ぐ
- 入力の分布を揃えてReLUの効果を最大化
nn.ReLU(inplace=True)
- 活性化関数ReLU
- 0未満の値を0に、0以上はそのまま
-
inplace=Trueはメモリ節約(入力のTensorを直接書き換える)
nn.Dropout(0.1)
- Dropout:ランダムに10%のユニットを無効化
- 効果:過学習防止、汎化性能向上
nn.Conv2d(256, num_classes, kernel_size=1)
- 1×1畳み込み
- 入力チャンネル256 → 出力チャネルnum_classes(21)
- 役割:各画素ごとに21クラスのスコアを出力
- この出力が最終的なロジットマップ(N,21,H,W)
まとめ
- 高次元特徴(2048ch)を低次元(256ch)に圧縮:(2048,7,7)→(256,7,7)
- バッチ正規化+活性化+Dropoutで安定化
- 1×1 Conv で画素ごとのクラススコアに変換(256,7,7)→(21,7,7)
- アップサンプリングで(21,7,7)→(21,224,224)
mIoU を求めるための定義+実装
# 下記リンク先のmIoU実装を利用
# https://github.com/wkentaro/pytorch-fcn/blob/master/torchfcn/utils.py
class mIoUScore(object):
def __init__(self, n_classes):
self.n_classes = n_classes
self.confusion_matrix = np.zeros((n_classes, n_classes))
def _fast_hist(self, label_true, label_pred, n_class):
mask = (label_true >= 0) & (label_true < n_class)
hist = np.bincount(
n_class * label_true[mask].astype(int) + label_pred[mask], minlength=n_class ** 2
).reshape(n_class, n_class)
return hist
def update(self, label_trues, label_preds):
for lt, lp in zip(label_trues, label_preds):
self.confusion_matrix += self._fast_hist(lt.flatten(), lp.flatten(), self.n_classes)
def get_scores(self):
hist = self.confusion_matrix
with np.errstate(divide='ignore', invalid='ignore'):
iou = np.diag(hist) / (hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist))
mean_iou = np.nanmean(iou)
return mean_iou
def reset(self):
self.confusion_matrix = np.zeros((self.n_classes, self.n_classes))
これは何をしているコード?
- confusion matrix(混同行列)の構築
self.confusion_matrix = np.zeros((n_classes, n_classes))
- 行=真のラベル(label_true)
- 列=予測ラベル(label_pred)
のマトリクス
例:クラス3の場合
| pred=0 | pred=1 | pred=2 | |
|---|---|---|---|
| true=0 | TP00 | FP01 | FP02 |
| true=1 | FP10 | TP11 | FP12 |
| true=2 | FP20 | FP21 | TP22 |
| この表がIoUの計算に使われる |
2. _fast_hist()の役割
hist = np.bincount(
n_class * label_true[mask].astype(int) + label_pred[mask],
minlength=n_class ** 2
).reshape(n_class, n_class)
- 各ピクセルの(true_class,pred_class)の組み合わせを数えて
n_class × c_classの混同行列として返す
3. update():ミニバッチごとに混同行列を更新
self.confusion_matrix += self._fast_hist(...)
4. get_scores():IoUとmIoUの計算
IoUの計算式:
IoU_c = TP_c / (TP_c + FP_c + FN_c)
コード内では
iou = np.diag(hist) / (hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist))
-
np.diag(hist)=TP(真のcを予測もcとした) -
hist.sum(axis=1)=tureの合計→TP+FN -
hist.sum(axis=0)=predの合計→TP+FP
そこからIoUを求めている
平均IoU(mIoU)
mean_iou = np.nanmean(iou)
5. reset():次のepoch用に初期化
num_classesの定義
num_classes = 21
モデルの構築とGPUへの転送
model = FCN(backbone=backbone, num_classes=num_classes)
model.to(device)
- FCN(セマンティックセグメンテーションモデル)を作成
- device(GPU or CPU)へモデルを送る
- GPUで学習するなら
device=cudaなど
- GPUで学習するなら
損失関数(Loss Function)の定義
loss_fn = nn.CrossEntropyLoss(ignore_index=255)
- セグメンテーションの基本である CrossEntropyLoss を選択
- 出力 shape: (N, 21, H, W)
- 正解ラベル:(N,H,W)(整数クラスID)
- One-hotは不要(内部で softmax + NLLLoss を計算)
- 255という値を無視
評価指標の準備
metrics = mIoUScore(num_classes)
- 先ほどのmIoUScoreクラスのインスタンスを作る
- 1epoch学習ごとに
metrics.update()-
metrics.get_scores()
でmIoUを算出
Optimizer(最適化手法)の定義
optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-4)
- Adamを使用することを指定
-
model.parameters()→モデルの全パラメータを更新対象に -
lr=1e-4→学習率 -
weight_decay=1e-4→L2正規化
学習エポック数の指定
n_epochs = 30
モデルの学習
from tqdm.notebook import tqdm
for epoch in range(n_epochs):
train_losses = []
valid_losses = []
metrics.reset()
# ======== TRAIN ==========
model.train()
pbar = tqdm(dataloader_train, unit="batch")
pbar.set_description(f"[train] Epoch {epoch+1}/{n_epochs}")
for image, target in pbar:
image, target = image.to(device), target.to(device)
optimizer.zero_grad()
output = model(image)
loss = loss_fn(output, target)
loss.backward()
optimizer.step()
train_losses.append(loss.item())
pbar.set_postfix(loss=sum(train_losses)/len(train_losses))
# ======== VALID ==========
model.eval()
pbar = tqdm(dataloader_valid, unit="batch")
pbar.set_description(f"[valid] Epoch {epoch+1}/{n_epochs}")
with torch.no_grad():
for image, target in pbar:
image, target = image.to(device), target.to(device)
output = model(image)
loss = loss_fn(output, target)
valid_losses.append(loss.item())
# 予測(argmax)
pred = output.argmax(1)
# mIoU 更新
metrics.update(target.cpu().numpy(), pred.cpu().numpy())
pbar.set_postfix(
loss=sum(valid_losses)/len(valid_losses),
mIoU=metrics.get_scores()
)
解説
- Epochごとの初期化
for epoch in range(n_epochs):
train_losses = []
valid_losses = []
metrics.reset()
毎epochの最初に:
-
train_losses:学習ロスを記録 -
valid_losses:検証ロスを記録 -
metrics.reset():mIoU の混同行列を初期化
2. TRAINモード
model.train()
これで
- BatchNormが学習モード
- Dropoutが有効
になる
3. Train DataLoaderをtqdmでラップ
pbar = tqdm(dataloader_train, unit="batch")
pbar.set_description(f"[train] Epoch {epoch+1}/{n_epochs}")
進捗バーを使って、表示を train Epoch x/y にする
4. Trainのミニバッチ処理
for image, target in pbar:
image, target = image.to(device), target.to(device)
optimizer.zero_grad()
output = model(image)
loss = loss_fn(output, target)
loss.backward()
optimizer.step()
train_losses.append(loss.item())
pbar.set_postfix(loss=sum(train_losses)/len(train_losses))
-
画像とラベルをGPUに送る
image, target = image.to(device), target.to(device) -
勾配初期化
optimizer.zero_grad() -
モデルの順伝播
output = model(image)
ここでFCNが(batch,21,224,224)の出力を返す。 -
損失計算(CrossEntropyLoss)
loss = loss_fn(output, target)- output:shape(N,21,H,W)
- target:shape(N,H,W)(クラスID)
なので CE Loss がピクセルごとに計算される
-
順伝播とパラメータ更新
loss.backward()
optimizer.step()
- 損失を記録し、平均を pbar に表示
train_losses.append(loss.item())
pbar.set_postfix(loss=sum(train_losses)/len(train_losses))
5. VALIDATIONモード
model.eval()
これにより
- Dropout OFF
- BatchNormが推論モード
になる
-
勾配計算なし
with torch.no_grad():
- Validation バッチ処理
for image, target in pbar:
image, target = image.to(device), target.to(device)
output = model(image)
loss = loss_fn(output, target)
valid_losses.append(loss.item())
TRAINと同じだが、backwordしないだけ
- mIoUを更新
pred = output.argmax(1)
metrics.update(target.cpu().numpy(), pred.cpu().numpy())
ここがセグメンテーション学習特有の処理
-
output.argmax(1)
21クラスの中から
最もスコアの高いクラスIDを選ぶ
⇒shape(N,H,W)
これが予測ラベルマップ
- Validation の結果を tqdm に表示
pbar.set_postfix(
loss=sum(valid_losses)/len(valid_losses),
mIoU=metrics.get_scores()
)
- Validation ロスの平均
- mIoU(セグメンテーション精度)
がリアルタイムに表示される
結果
[train] Epoch 1/40: 100%
114/114 [00:31<00:00, 3.89batch/s, loss=1.37]
[valid] Epoch 1/40: 100%
7/7 [00:00<00:00, 8.19batch/s, loss=0.834, mIoU=0.423]
[train] Epoch 2/40: 100%
114/114 [00:30<00:00, 3.84batch/s, loss=0.579]
[valid] Epoch 2/40: 100%
7/7 [00:00<00:00, 7.49batch/s, loss=0.591, mIoU=0.473]
[train] Epoch 3/40: 100%
114/114 [00:32<00:00, 3.79batch/s, loss=0.359]
[valid] Epoch 3/40: 100%
7/7 [00:00<00:00, 8.08batch/s, loss=0.507, mIoU=0.5]
[train] Epoch 4/40: 100%
114/114 [00:31<00:00, 3.78batch/s, loss=0.266]
[valid] Epoch 4/40: 100%
7/7 [00:00<00:00, 8.01batch/s, loss=0.444, mIoU=0.526]
[train] Epoch 5/40: 100%
114/114 [00:32<00:00, 3.59batch/s, loss=0.217]
[valid] Epoch 5/40: 100%
7/7 [00:01<00:00, 6.56batch/s, loss=0.444, mIoU=0.51]
[train] Epoch 6/40: 100%
114/114 [00:32<00:00, 3.68batch/s, loss=0.185]
[valid] Epoch 6/40: 100%
7/7 [00:00<00:00, 8.30batch/s, loss=0.422, mIoU=0.51]
[train] Epoch 7/40: 100%
114/114 [00:32<00:00, 3.58batch/s, loss=0.169]
[valid] Epoch 7/40: 100%
7/7 [00:00<00:00, 8.30batch/s, loss=0.417, mIoU=0.497]
[train] Epoch 8/40: 100%
114/114 [00:32<00:00, 3.66batch/s, loss=0.157]
[valid] Epoch 8/40: 100%
7/7 [00:00<00:00, 8.23batch/s, loss=0.398, mIoU=0.527]
[train] Epoch 9/40: 100%
114/114 [00:32<00:00, 3.34batch/s, loss=0.143]
[valid] Epoch 9/40: 100%
7/7 [00:00<00:00, 8.25batch/s, loss=0.407, mIoU=0.525]
[train] Epoch 10/40: 100%
114/114 [00:32<00:00, 3.71batch/s, loss=0.133]
[valid] Epoch 10/40: 100%
7/7 [00:00<00:00, 8.25batch/s, loss=0.4, mIoU=0.542]
[train] Epoch 11/40: 100%
114/114 [00:32<00:00, 3.37batch/s, loss=0.127]
[valid] Epoch 11/40: 100%
7/7 [00:00<00:00, 7.28batch/s, loss=0.436, mIoU=0.535]
[train] Epoch 12/40: 100%
114/114 [00:32<00:00, 3.71batch/s, loss=0.121]
[valid] Epoch 12/40: 100%
7/7 [00:00<00:00, 8.39batch/s, loss=0.41, mIoU=0.519]
[train] Epoch 13/40: 100%
114/114 [00:32<00:00, 3.41batch/s, loss=0.116]
[valid] Epoch 13/40: 100%
7/7 [00:01<00:00, 5.99batch/s, loss=0.395, mIoU=0.526]
[train] Epoch 14/40: 100%
114/114 [00:32<00:00, 3.69batch/s, loss=0.111]
[valid] Epoch 14/40: 100%
7/7 [00:00<00:00, 8.26batch/s, loss=0.415, mIoU=0.534]
[train] Epoch 15/40: 100%
114/114 [00:32<00:00, 3.45batch/s, loss=0.108]
[valid] Epoch 15/40: 100%
7/7 [00:01<00:00, 6.31batch/s, loss=0.397, mIoU=0.543]
[train] Epoch 16/40: 100%
114/114 [00:32<00:00, 3.70batch/s, loss=0.105]
[valid] Epoch 16/40: 100%
7/7 [00:00<00:00, 8.06batch/s, loss=0.397, mIoU=0.552]
[train] Epoch 17/40: 100%
114/114 [00:32<00:00, 3.58batch/s, loss=0.1]
[valid] Epoch 17/40: 100%
7/7 [00:00<00:00, 6.88batch/s, loss=0.406, mIoU=0.528]
[train] Epoch 18/40: 100%
114/114 [00:32<00:00, 3.72batch/s, loss=0.0989]
[valid] Epoch 18/40: 100%
7/7 [00:00<00:00, 8.01batch/s, loss=0.425, mIoU=0.522]
[train] Epoch 19/40: 100%
114/114 [00:32<00:00, 3.67batch/s, loss=0.113]
[valid] Epoch 19/40: 100%
7/7 [00:00<00:00, 6.70batch/s, loss=0.749, mIoU=0.425]
[train] Epoch 20/40: 100%
114/114 [00:32<00:00, 3.64batch/s, loss=0.31]
[valid] Epoch 20/40: 100%
7/7 [00:00<00:00, 7.60batch/s, loss=0.762, mIoU=0.308]
[train] Epoch 21/40: 100%
114/114 [00:32<00:00, 3.67batch/s, loss=0.227]
[valid] Epoch 21/40: 100%
7/7 [00:00<00:00, 7.99batch/s, loss=0.496, mIoU=0.464]
[train] Epoch 22/40: 100%
114/114 [00:32<00:00, 3.62batch/s, loss=0.142]
[valid] Epoch 22/40: 100%
7/7 [00:00<00:00, 8.17batch/s, loss=0.465, mIoU=0.453]
[train] Epoch 23/40: 100%
114/114 [00:32<00:00, 3.69batch/s, loss=0.113]
[valid] Epoch 23/40: 100%
7/7 [00:00<00:00, 8.28batch/s, loss=0.405, mIoU=0.555]
[train] Epoch 24/40: 100%
114/114 [00:32<00:00, 3.58batch/s, loss=0.101]
[valid] Epoch 24/40: 100%
7/7 [00:00<00:00, 7.77batch/s, loss=0.429, mIoU=0.532]
[train] Epoch 25/40: 100%
114/114 [00:32<00:00, 3.69batch/s, loss=0.0946]
[valid] Epoch 25/40: 100%
7/7 [00:00<00:00, 7.77batch/s, loss=0.439, mIoU=0.54]
[train] Epoch 26/40: 100%
114/114 [00:32<00:00, 3.56batch/s, loss=0.0894]
[valid] Epoch 26/40: 100%
7/7 [00:00<00:00, 8.21batch/s, loss=0.429, mIoU=0.528]
[train] Epoch 27/40: 100%
114/114 [00:32<00:00, 3.64batch/s, loss=0.0873]
[valid] Epoch 27/40: 100%
7/7 [00:00<00:00, 8.16batch/s, loss=0.442, mIoU=0.538]
[train] Epoch 28/40: 100%
114/114 [00:32<00:00, 3.40batch/s, loss=0.0858]
[valid] Epoch 28/40: 100%
7/7 [00:00<00:00, 8.19batch/s, loss=0.459, mIoU=0.517]
[train] Epoch 29/40: 100%
114/114 [00:32<00:00, 3.71batch/s, loss=0.0839]
[valid] Epoch 29/40: 100%
7/7 [00:00<00:00, 7.71batch/s, loss=0.441, mIoU=0.541]
[train] Epoch 30/40: 100%
114/114 [00:32<00:00, 3.31batch/s, loss=0.0811]
[valid] Epoch 30/40: 100%
7/7 [00:00<00:00, 7.05batch/s, loss=0.455, mIoU=0.526]
[train] Epoch 31/40: 100%
114/114 [00:32<00:00, 3.71batch/s, loss=0.0805]
[valid] Epoch 31/40: 100%
7/7 [00:00<00:00, 8.36batch/s, loss=0.454, mIoU=0.529]
[train] Epoch 32/40: 100%
114/114 [00:32<00:00, 3.37batch/s, loss=0.0794]
[valid] Epoch 32/40: 100%
7/7 [00:01<00:00, 6.16batch/s, loss=0.466, mIoU=0.533]
[train] Epoch 33/40: 100%
114/114 [00:32<00:00, 3.73batch/s, loss=0.0798]
[valid] Epoch 33/40: 100%
7/7 [00:00<00:00, 7.95batch/s, loss=0.467, mIoU=0.542]
[train] Epoch 34/40: 100%
114/114 [00:32<00:00, 3.34batch/s, loss=0.0792]
[valid] Epoch 34/40: 100%
7/7 [00:00<00:00, 6.55batch/s, loss=0.447, mIoU=0.53]
[train] Epoch 35/40: 100%
114/114 [00:32<00:00, 3.64batch/s, loss=0.0781]
[valid] Epoch 35/40: 100%
7/7 [00:00<00:00, 7.98batch/s, loss=0.471, mIoU=0.536]
[train] Epoch 36/40: 100%
114/114 [00:32<00:00, 3.40batch/s, loss=0.0769]
[valid] Epoch 36/40: 100%
7/7 [00:01<00:00, 6.47batch/s, loss=0.462, mIoU=0.527]
[train] Epoch 37/40: 100%
114/114 [00:32<00:00, 3.70batch/s, loss=0.0762]
[valid] Epoch 37/40: 100%
7/7 [00:00<00:00, 7.98batch/s, loss=0.486, mIoU=0.523]
[train] Epoch 38/40: 100%
114/114 [00:32<00:00, 3.56batch/s, loss=0.0763]
[valid] Epoch 38/40: 100%
7/7 [00:01<00:00, 6.32batch/s, loss=0.493, mIoU=0.519]
[train] Epoch 39/40: 100%
114/114 [00:32<00:00, 3.67batch/s, loss=0.0761]
[valid] Epoch 39/40: 100%
7/7 [00:00<00:00, 8.18batch/s, loss=0.528, mIoU=0.5]
[train] Epoch 40/40: 100%
114/114 [00:32<00:00, 3.66batch/s, loss=0.076]
[valid] Epoch 40/40: 100%
7/7 [00:01<00:00, 5.85batch/s, loss=0.499, mIoU=0.492]
mIoUは高くて5付近だった。
参考:
| 用途 | 必要な mIoU の目安 | 理由 |
|---|---|---|
| 学習・研究のデモ | 0.55〜0.65 | 形状はそれっぽくなるが、境界や小物体で失敗しやすい |
| 簡易用途(物体位置や大まかな領域が分かればOK) | 0.65〜0.75 | 領域の確保は十分、境界が甘い |
| 一般的な実務レベル(産業用途の初期導入) | 0.75〜0.85 | 推論結果が安定し、誤検出が少ない |
| 高精度が必要(医療、工業検査、精密な領域が重要) | 0.85〜0.90+ | 境界精度・小物体の検出が高精度に必要 |
| 非常に厳密な運用(自動運転、医用診断) | 0.90〜0.95 以上 | ミスが致命的になる領域 |
学習・研究のでもの最低ラインのスコアしか出せなかった。
学習ログによると、途中までは順調 → 中盤で大きく崩れる → 過学習して収束
というパターンになった。
- 前半:1~17epoch
- train lossが順調に低下(1.37 → 0.1)
- valid lossも低下
- mIoUも0.42 → 0.55付近まで上昇
ここまではいい流れ
- 中盤:19~20epoch
Epoch18: loss=0.425, mIoU=0.522
Epoch19: loss=0.749, mIoU=0.425 ← 急に悪化
Epoch20: loss=0.762, mIoU=0.308 ← 完全崩壊
**これは典型的な学習率(LR)が高すぎて発散した兆候
-
非常に難しいmini-batchに当たって勾配が暴れた
-
大きく間違えた方向に更新してしまい、重みが壊れた
-
以降は回復しきれずに、性能が不安定なまま減衰
-
後半21~40epoch
- train lossはさらにゆっくり下がる(0.08くらい)
- でもvalid loss / mIoUはほぼ横ばいか微妙に悪化
- 大幅に向上する気配はない
→ **完全に過学習しつつ、19~20epochの崩壊の影響
改善案
-
学習率を下げる
-
LearningRateSchedulerを追加
-
early stoppingを入れる
-
weight decayを入れる
-
batch normalizationのmomentumを調整
-
損失関数の強化
- CrossEntropy + Dice Loss
- Focal Loss(背景の多いデータで有効)
- Lovász-Softmax Loss(mIoU 最適化に特化)
7. データ拡張の追加
- 色系
- 明るさ/コントラスト変化
- 彩度変更
- 幾何系
- ランダムスケール
- ランダムクロップ
- 左右反転
- 回転
- CutMixSeg / Mixing augmentation
セグメンテーション用のCutMix
境界学習が強くなる
8. モデルの改善
今回はFCNを採用したが、より強いアーキテクチャを使用する
-
DeepLabV3 / DeepLabV3+
- Atrous Convolution(空洞畳み込み)
- ASPP によるマルチスケール
→ COCOやPascal VOCの標準モデル
-
U-Net(小~中規模データ)
- encoder-decoder
- skip connectionによる高精度
-
UNet++/UNet3+(U-Netの強化版)
skipの接続数増加で高精度 -
SegFormer(Transformerベース)
軽度・高精度
mIoUに適している
9. 後処理の追加
-
CRF(Conditional Random Fields)
境界がシャープになる -
Test-Time Augmentation(TTA)
- 反転して推論 → 元に戻す
- スケールを変えて推論
統合すると精度が上がる
10. バッチサイズを増やす
BatchNorm使用時は効果あり
11. learning rate warmupを入れる
最初の5epochくらいはLR(学習率)を小さくする
→勾配爆発を防げる
12. ラベル平滑化(Label Smoothing)の追加
13. 学習率探索(LR finder)の追加
14. より大きい画像で学習する
セグメンテーションでは解像度が上がると精度が向上する
しかし、処理は重くなるのでGPU依存
あとがき
今回は初めてFCNで物体認識モデルを構築してみた。
解説は調べながら書いているが、ところどころ間違っているor誤解しているところがあるかもしれない(絶対ある)ので、半信半疑で読んでもらいたい。
時間が空き次第このコードをベースとして、mIoUスコアを向上させて行くつもりです。
こんなくだらない記事を読んでいただき、ありがとうございました。
