Dogs vs. Cats Redux でCNNを学ぶ

CNNで遊ぶ

今回は、CNNで画像分類して遊びます。

参考文献:

Kaggleに挑む深層学習プログラミングの極意 (KS情報科学専門書)

Kaggleに挑む深層学習プログラミングの極意 (KS情報科学専門書) | 小嵜 耕平, 秋葉 拓哉, 林 孝紀, 石原 祥太郎 |本 | 通販 | Amazon
Amazonで小嵜 耕平, 秋葉 拓哉, 林 孝紀, 石原 祥太郎のKaggleに挑む深層学習プログラミングの極意 (KS情報科学専門書)。アマゾンならポイント還元本が多数。小嵜 耕平, 秋葉 拓哉, 林 孝紀, 石原 祥太郎作品ほか、お急...

深層学習 改訂第2版 (機械学習プロフェッショナルシリーズ)

深層学習 改訂第2版 (機械学習プロフェッショナルシリーズ) | 岡谷 貴之 |本 | 通販 | Amazon
Amazonで岡谷 貴之の深層学習 改訂第2版 (機械学習プロフェッショナルシリーズ)。アマゾンならポイント還元本が多数。岡谷 貴之作品ほか、お急ぎ便対象商品は当日お届けも可能。また深層学習 改訂第2版 (機械学習プロフェッショナルシリーズ...

Kaggleの「Dogs vs. Cats Redux」を使います。

犬と猫の画像があって、それを画像分類するものです。

評価は対数損失(LogLoss)とします。

$$\text{LogLoss} = – \frac{1}{N} \sum_{i=1}^{N} \left[ y_i \log \hat{y}_i + (1 – y_i) \log (1 – \hat{y}_i) \right]$$

実装

モデルは既存のアーキテクチャを用いてResNetを使います。

pretrained=TrueでImageNet(大規模データベース)での事前学習済みの重みを読み込むことが可能。

import torchvision
# アーキテクチャはResNet-50を使用、事前学習の重みを加える
model = torchvision.models.resnet50(pretrained=True)
model = model.to(device)
# 出力層を1000(デフォルト)から2に変更
model.fc = torch.nn.Linear(model.fc.in_features, 2)

評価関数がlogloss, クロスエントロピーなので、目的関数もクロスエントロピーにします。

lossfun = torch.nn.CrossEntropyLoss()

あと、出力層は確率である必要があるので、ソフトマックス関数を適用します。

pred_fun = torch.nn.Softmax(dim=1)

んで、まずは訓練データセットの中に、cat~ファイルとdog~ファイルが混在しているので、ターミナルで以下のようにいじります。

"""
Dog_vs_Cat--test--unknown
|
-train--cat
|
-dog

ターミナルコード find . -name "cat.*" -not -path "cat/*" -exec mv {} cat/ \;
"""

次に、訓練セットから検証セットを分割するのですが、訓練セットだけで25000枚もあるので、一旦訓練用と検証用の枚数上限を100枚にして分割してちゃんと動くかチェックします。dryrun=Falseで本番仕様です。

#検証セット分割用関数
#dryrun=Trueでは画像枚数を100枚に制限(テスト用)
def setup_train_val_split(labels, dryrun=True, seed=0):
x = np.arange(len(labels))
y = np.array(labels)
splitter = sklearn.model_selection.StratifiedShuffleSplit(
n_splits=1, train_size=0.8, random_state=seed
)
train_induces, val_indices = next(splitter.split(x, y))

if dryrun:
train_induces =np.random.choice(train_induces, 100, replace=False)
val_indices = np.random.choice(val_indices, 100, replace=False)
return train_induces, val_indices

次に、データセットに対して以下の前処理を行います。

from torchvision import transforms
#画像の前処理用関数
def setup_center_crop_transform():
return transforms.Compose(
[transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406],[0.229, 0.224, 0.225]),
]
)

#訓練セットと検証セットを用意
def setup_train_val_datasets(data_dir, dryrun=False):
dataset = torchvision.datasets.ImageFolder(
os.path.join(data_dir, "train"),
transform=setup_center_crop_transform(),
)
if len(dataset) == 0:
raise ValueError("データセットが空です。正しいパスを指定してください。")
labels = get_labels(data_dir)
if len(labels) == 0:
raise ValueError("ラベルが見つかりません。データ構造を確認してください。")
train_induces, val_induces = setup_train_val_split(labels, dryrun)
train_dataset = torch.utils.data.Subset(dataset, train_induces)
val_dataset = torch.utils.data.Subset(dataset, val_induces)

return train_dataset, val_dataset

これは、ImageNetで行われている処理に合わせただけです。

次に、データセットを一度に全てモデルにぶち込むのは困難なことが多いので、ミニバッチごとに処理えをすることになるが、その際PyTorchのDataLoderを用いると、自動でミニバッチを作成し、シャッフルとかもしてくれます。

#データローダーの準備
def setup_train_val_loaders(data_dir, batch_size, dryrun=False):
train_dataset, val_dataset =setup_train_val_datasets(
data_dir, dryrun=dryrun
)
train_loader = torch.utils.data.DataLoader(
train_dataset,#対象のデータに対して
batch_size=batch_size,#このサイズごとにミニバッチを作成
shuffle=True,#シャッフル
drop_last=True,#データ数がわききれない時にデータを割り切れるように削除
num_workers=8 #並列処理
)
val_loader = torch.utils.data.DataLoader(
val_dataset, batch_size=batch_size, num_workers=8
)
return train_loader, val_loader

ここまででデータの準備ができたので、次は学習ループを作成します。

#学習ループ

#訓練用
def train_1epoch(model, train_loader, lossfun, optimizer, device):
model = model.to(device)
model.train()
total_loss, total_acc = 0.0, 0.0

for x, y in tqdm(train_loader):
x = x.to(device)
y = y.to(device)

optimizer.zero_grad()
out = model(x)
loss = lossfun(out, y)
_, pred = torch.max(out.detach(), 1)
loss.backward()
optimizer.step()

total_loss += loss.item() * x.size(0)
total_acc += torch.sum(pred == y)

avg_loss = total_loss / len(train_loader.dataset)
avg_acc = total_acc / len(train_loader.dataset)
return avg_acc, avg_loss

#検証用
def validate_1epoch(model, val_loader, lossfun, device):
model.eval()
total_loss, total_acc = 0.0, 0.0

with torch.no_grad():
for x, y in tqdm(val_loader):
x = x.to(device)
y = y.to(device)

out = model(x)
loss = lossfun(out, y)
_, pred = torch.max(out.detach(), 1)

total_loss += loss.item() * x.size(0)
total_acc += torch.sum(pred == y)


avg_loss = total_loss / len(val_loader.dataset)
avg_acc = total_acc / len(val_loader.dataset)
return avg_acc, avg_loss

#訓練セットと検証セットの正答率とクロスエントロピー損失を表示
def train(model, optimizer, train_loader, val_loader, n_epochs, device, lr_scheduler):
lossfun = torch.nn.CrossEntropyLoss()
for epoch in tqdm(range(n_epochs)):
train_acc, train_loss, = train_1epoch(
model, train_loader, lossfun, optimizer, device
)
val_acc, val_loss = validate_1epoch(model, val_loader, lossfun, device)
print(f"epoch={epoch}, train loss={train_loss}, train accuracy={train_acc},"
f"val loss={val_loss}, val accuracy={val_acc}")
lr_scheduler.step()

これで学習は完了する・・・はず。

次にテストセットの予測値csv

#テスト提出用関数
def setup_test_loader(data_dir, batch_size, dryrun):
dataset = torchvision.datasets.ImageFolder(
os.path.join(data_dir, "test"), transform=setup_center_crop_transform()
)
image_ids = [
os.path.splitext(os.path.basename(path))[0] for path, _ in dataset.imgs
]
if dryrun:
dataset = torch.utils.data.Subset(dataset, range(0, 100))
image_ids = image_ids[:100]
loader = torch.utils.data.DataLoader(
dataset, batch_size=batch_size, num_workers=12)
return loader, image_ids

def predict(model, loader, device):
pred_fun = torch.nn.Softmax(dim=1)
preds=[]
for x, _ in tqdm(loader):
with torch.set_grad_enabled(False):
x = x.to(device)
y = pred_fun(model(x))
y = y.cpu().numpy()
y = y[:, 1]
preds.append(y)
preds = np.concatenate(preds)
return preds

def write_prediction(image_ids, prediction, out_path):
with open(out_path, "w") as f:
f.write("id,label\n")
for i, p in zip(image_ids, prediction):
f.write("{},{}\n".format(i, p))

最後に、諸々実施します

# 訓練実施
device = torch.device("mps")
# アーキテクチャはResNet-50を使用、事前学習の重みを加える
model = torchvision.models.resnet50(pretrained=True)
model = model.to(device)
# 出力層を1000(デフォルト)から2に変更
model.fc = torch.nn.Linear(model.fc.in_features, 2)

# 最適化関数はSGD
n_epochs = 10
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=0.0001)
data_dir = "/Users/~/Dog_vs_Cat"
train_loader, val_loader = setup_train_val_loaders(data_dir=data_dir, batch_size=64, dryrun=False)
n_iterations = len(train_loader) * n_epochs
lr_scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, n_iterations)
#コサイン関数で学習率を設定
train(model=model, optimizer=optimizer, train_loader=train_loader, val_loader=val_loader,
n_epochs=n_epochs, device=device, lr_scheduler=lr_scheduler)

# 提出用csvの出力
test_loader, image_id = setup_test_loader(
data_dir, batch_size=64, dryrun=False
)
preds = predict(model, test_loader, device=device)
out_dir = "/Users/~/Dog_vs_Cat/submission/out.csv"
write_prediction(image_ids=image_id, prediction=preds, out_path=out_dir)

上記を実行して、submitすると、スコアは0.13265

うーんと、700位くらい?

all0.5が0.69なので、それと比べるとしっかり予測できてそうです。

最適化アルゴリズムと学習率スケジューリングについて

最適化アルゴリズム

最適化アルゴリズム、色々あってわからないです。。。

この本ではadammomemtum SGDがいいぞって書いてあったのでその二つを紹介します。

確率的勾配降下法:SGD

まずはただの勾配降下法について。

ニューラルネットワークの学習では、損失関数(今回はlogloss)を最小化させるような重みwを設定する必要がある。

そこで、勾配

$$\nabla E = \frac{\partial L}{\partial w} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial w}$$

は局所解に至るまでの傾きであるから、初期値w1から勾配の負の方向に向かって\(\epsilon\nabla E\)だけwをずらして再度勾配を計算する。

ここで\(\epsilon\)は学習率である。

これを繰り返して、\(\nabla E\)がある一定以下の絶対値を取ったときにそのwを求める。

勾配降下法ははシンプルゆえに極小解への収束速度は早くない。が、パラメーターが多すぎる時や、2次微分以上が難しい時でも使用できる。

次に確率的確率勾配法について

勾配降下法はすべての訓練データを用いて勾配を計算し、最適な重みを計算していました。

確率的勾配降下法(SGD)では訓練データの一部のサンプルで勾配を計算し、重みwを更新した後、別のサンプルを用いて勾配を計算、重みを更新、そしてさらに別のサンプルで勾配を計算、、、と言うふうにwの更新ごとにサンプルを変えます。

これを行うことで、局所最適解にはまりづらくなります。

最後にモメンタムについて

SGDではwの更新ごとに異なる目的関数を考えるため、wの更新具合にはばらつきが生じます。

これを安定化する手法がモメンタムです。

これは、重みの修正値に前回の重みの修正量の何割かを加算する方法

$$w_{t+1} = w_t – \epsilon \nabla E_t + \mu v_t$$

$$v_t = w_t – w_{t-1}$$

となります。μは前回の修正値の何割を加算するかで、大体0.5-0.9の範囲らしいです。

Adam

まず、SGDと同様に勾配を考えます。

$$g_t = \nabla_{\theta} L(\theta_t)$$

勾配のモーメントを指数移動平均を考えることで計算します。

一次モーメント

$$m_t = \beta_1 m_{t-1} + (1 – \beta_1) g_t$$

2次モーメント

$$v_t = \beta_2 v_{t-1} + (1 – \beta_2) g_t^2$$

そしてこれを用いて以下のように重みを計算します。

$$\theta_{t+1} = \theta_t – \frac{\alpha}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t$$

AdamはSGDよりも安定していい結果が得られることが多いが、CNNの画像認識ではSGDの方が汎化性能が上がったりするらしい。

学習率スケジューリング

今回はコサイン関数で学習率を設定しています。本にそう書いてあったから・・・

調べたところによると、コサイン関数にすることのメリットは

  • 初期の学習率を上げることで大域的最適解に近づくまでの速度が速い)

→いきなり局所解にハマるリスクが低い

  • 連続関数なので、学習率の減少が滑らかで、学習が不安定になることを避けられる。
  • 後半の学習率が減衰するので過学習を防げる
  • ハイパーパラメータの調整が少なくてすむ(最大エポック数の設定のみ)

といった感じみたいです。

おまけ

最後にうちのにゃんこを予測させます。

ちょっとデータセットより画像サイズが大きいんだけど大丈夫かな、、、

予測値は、、、、、

5.769051469178832e-12

画像が犬である確率を出しているので、この結果は0.000000000576905147%の確率で犬!

まだうちの子が犬の可能性が残っているのか、、、

今日はここまでです!

まとめ

うちのにゃんこが犬である可能性は

まだ残されている、、、、!

コメント

タイトルとURLをコピーしました