はじめに
深層学習を用いた分類モデルに、人間には知覚できない程度に微小なノイズを加えたデータを入力すると、大きく分類を誤ることがあります。
このような入力を「Adversarial Examples(敵対的サンプル)」と呼び、攻撃手法、防御手法ともに様々な研究がされています。
特に、人間の目にも分かりやすく、連続値を扱う画像分野での研究が盛んな印象があります。
それでは、そもそもなぜ分類モデルは微小なノイズで出力を大きく変えてしまうのでしょうか。
その理由の一つとして、深層学習モデルは、人間には知覚できないような微小な特徴量を分類の際に用いており、これが変化してしまったから、というのが理由の一つとして考えられています。
この主張を裏付けるための実験をした論文として、NeurIPS2019で発表された"Adversarial Examples Are Not Bugs, They Are Features"[1]という論文があります。
この記事では、この論文の中で行われた実験の一つを簡易的に再現してみたいと思います。
論文の概要
この論文[1]では、深層学習モデルが扱う特徴量を「Robust Features(ロバスト特徴量)」と「Non-robust Features(非ロバスト特徴量)」の二つに分けています。
ロバスト特徴量はノイズに頑健な特徴量で、画像であれば、猫のヒゲや、車のタイヤなど、人間が知覚できるような分かりやすい特徴をイメージしていただければよいと思います。多少ノイズを加えたとしても、特徴が損なわれず、クラス間で分布が大きく異なるものになります。詳しい数学的な定義を知りたい方は元論文[1]をご参照ください。
非ロバスト特徴量はロバスト特徴量ではないような特徴量を指します。すなわち、ノイズを加えてしまうとクラスの識別に有意には役立たないものとなります。
この論文の主張は以下の二点です。
- 深層学習モデルはロバスト特徴量と非ロバスト特徴量の二つを用いて識別を行っている。
- 非ロバスト特徴量だけでも十分な学習を行うことができる。
この主張を確認するため、以下の二つの実験が行われています。

(図は文献[1]中Figure 1より引用)
一つ目の実験では、画像から抽出された特徴量をロバスト特徴量と非ロバスト特徴量に分離します。このとき、ロバスト特徴量のみで学習したモデルは、通常のデータセットに対しても、敵対的サンプルを含むデータセットに対しても高い精度が出ます。また、非ロバスト特徴量のみで学習したモデルでも、通常のデータセットに対しては高い性能が出ることを確認しています。ただし、当然ながら敵対的サンプルを含むデータセットへの精度は低いです。
この実験から、ロバスト特徴量と非ロバスト特徴量が存在しており、ロバスト特徴量がノイズに頑健な予測ができること、非ロバスト特徴量だけでも十分に学習ができることが確認できます。
二つ目の実験では、最初に通常のデータセットで学習済みの深層学習モデルを用意します。このモデルが、出力を間違えるようにデータにノイズを追加していきます。画像の例では、犬の画像を猫と誤識別するように微小なノイズを加えていきます。
こうして得られた画像に「猫」とラベル付した新たなデータを作成します。当然、この画像は多くの人間の目には犬に見えます。
同等の手順を踏んで、すべての画像に人間の目には違和感のあるラベルのついたデータセットを用意します。
次に、このようにして作られた新たなデータセットで、新たなモデルを学習します。この画像は、ロバスト特徴量が犬、非ロバスト特徴量が猫の画像を「猫」というラベルで学習していることになります。
このようにして作られた新たなモデルを、ノイズを加えていないテスト用のデータセット(ロバスト特徴量も非ロバスト特徴量もラベルも猫になっているもの)で評価すると、一定の性能が出るというものです。
ロバスト特徴量側に引っ張られて予測してしまうこともあるので、極めて高い精度が出るということはないのですが、非ロバスト特徴量が意味がある特徴量であること、敵対的サンプルが機能する理由の説明にはなると思います。
今回の記事では二つ目の実験を取り上げて実装を行い、実際にこのようなモデルが作れるのか、出来上がったノイズ付与画像がどんなものなのかを確認してみたいと思います。
実装
今回の実験は以下の環境で行なっています。
Python 3.9.19
---
pytorch 1.12.1
torchvision 0.13.1
元論文[1]では、CIFAR-10とImageNetを使って実験しているのですが、今回の記事ではCIFAR-10に絞って実験します。
データセットとモデルの読み込み
最初に必要なライブラリとCIFAR-10の読み込みを行います。
また、元論文[1]に倣って、ResNet-50のアーキテクチャを利用するため、モデルの読み込みと、それに合わせたtransformを実装しておきます。
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
import torchvision.utils as vutils
from tqdm.notebook import tqdm
from matplotlib import pyplot as plt
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
batch_size=64
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size, shuffle=True)
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
testloader = torch.utils.data.DataLoader(testset, batch_size=batch_size, shuffle=False)
model = torch.hub.load('pytorch/vision:v0.10.0', 'resnet50', pretrained=True)
model.fc = nn.Linear(2048, 10)
model.to(device)
通常の学習
最初に、CIFAR-10を使って通常の学習を行います。
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
num_epoch=20
for epoch in tqdm(range(num_epoch)):
running_loss = 0.0
for data in trainloader:
X, y_true = data
X = X.to(device)
y_true = y_true.to(device)
optimizer.zero_grad()
y_pred = model(X)
loss = criterion(y_pred, y_true)
loss.backward()
optimizer.step()
running_loss += loss.item()
print(f'[{epoch + 1}] loss: {running_loss / len(trainloader):.3f}')
得られたモデルの性能を確認しておきます。
classes = ('plane', 'car', 'bird', 'cat', 'deer',
'dog', 'frog', 'horse', 'ship', 'truck')
correct_pred = {classname: 0 for classname in classes}
total_pred = {classname: 0 for classname in classes}
with torch.no_grad():
for data in tqdm(testloader):
images, labels = data
images = images.to(device)
labels = labels.to(device)
outputs = model(images)
_, predictions = torch.max(outputs, 1)
for label, prediction in zip(labels, predictions):
if label == prediction:
correct_pred[classes[label]] += 1
total_pred[classes[label]] += 1
sum_accuracy = 0
for classname, correct_count in correct_pred.items():
accuracy = 100 * float(correct_count) / total_pred[classname]
print(f'Accuracy for class: {classname:5s} is {accuracy:.1f} %')
sum_accuracy += accuracy
print("---")
print(f"Total Accuracy: {sum_accuracy / len(classes):.1f} %") # CIFAR-10は各クラス同じ枚数ずつなので、このやり方でも問題ない
Accuracy for class: plane is 90.9 %
Accuracy for class: car is 92.0 %
Accuracy for class: bird is 82.9 %
Accuracy for class: cat is 84.8 %
Accuracy for class: deer is 94.8 %
Accuracy for class: dog is 87.1 %
Accuracy for class: frog is 93.4 %
Accuracy for class: horse is 89.8 %
Accuracy for class: ship is 95.1 %
Accuracy for class: truck is 94.1 %
---
Total Accuracy: 90.5 %
90%程度のAccuracyが出ているので、問題なく学習できていそうです。
敵対的サンプルの作成
次に、得られた分類器を利用して、敵対的サンプルを作成していきます。ここでは、元のクラス順から一つクラスをずらした先を新たなラベルにします。
そして、分類器の学習に使ったトレーニングデータを利用し、これを加工していきます。
作成の手順としては、まずランダムなノイズを用意し、元の画像に付与します。ノイズは勾配計算をできるようにしておき、これを更新していくことで、敵対的サンプルを作ります。
次に、先ほど作成したモデルに、ノイズを付与した画像を入力します。モデルの出力と、新たなラベルの間で損失関数の計算を行い、損失関数の値が小さくなる方向にノイズの更新を行います。
この際に、ノイズが大きくなりすぎないように、大きさに制限をかけます。ここでは、L2ノルムを利用し、一定の値を超えないようにしています。
以下がこれを実装したものになります。
label_dict = {i:(i+1)%10 for i in range(10)} # ラベルの変換先
trainloader = torch.utils.data.DataLoader(trainset, batch_size=batch_size, shuffle=False) # 後で結果を見やすくするためshuffleを外す
new_dataset_X = []
new_dataset_y = []
loss_fn = nn.CrossEntropyLoss()
alpha=0.1 # 学習率
eps=5 # ノイズの大きさの上限
model.eval()
for data in tqdm(trainloader):
images, labels = data
images = images.to(device)
target_class = [label_dict[label.item()]for label in labels]
target_tensor = torch.tensor(target_class).to(device)
noise_data = (torch.randn(size=(images.shape[0],3, 224, 224))*0.02).to(device)
noise_data.requires_grad_()
for i in range(100): # ノイズの更新
noisy_image = images + noise_data
outputs = model(noisy_image.reshape(-1,3,224,224))
loss = loss_fn(outputs, target_tensor)
grad = torch.autograd.grad(loss, noise_data, retain_graph=False, create_graph=False)[0]
grad_step = -grad
grad_flat = grad_step.view(grad_step.size(0), -1)
grad_norm = torch.norm(grad_flat, p=2, dim=1, keepdim=True)
grad_norm = torch.clamp(grad_norm, min=1e-12)
grad_dir = (grad_flat / grad_norm).view_as(noise_data)
noise_data = noise_data + alpha * grad_dir
# ノイズの大きさを制限する
noise_flat = noise_data.view(noise_data.size(0), -1)
noise_norm = torch.norm(noise_flat, p=2, dim=1, keepdim=True)
exceed_mask = (noise_norm > eps).float()
scaled = (noise_flat / noise_norm) * eps
new_noise_flat = exceed_mask * scaled + (1.0 - exceed_mask) * noise_flat
noise_data = new_noise_flat.view_as(noise_data)
noise_data = noise_data.detach()
best_noisy_image = images + noise_data
best_noisy_image = best_noisy_image.to("cpu")
new_dataset_X.append(best_noisy_image)
new_dataset_y.append(target_tensor)
作成した画像はtorchのデータセットの形にしておきます。
class Noisy_Dataset(torch.utils.data.Dataset):
def __init__(self, transform=None):
self.transform = transform
self.data = torch.cat(new_dataset_X)
self.label = torch.cat(new_dataset_y)
self.datanum= len(self.label)
def __len__(self):
return self.datanum
def __getitem__(self, idx):
out_data = self.data[idx]
out_label = self.label[idx]
if self.transform:
out_data = self.transform(out_data)
return out_data, out_label
noisy_dataset = Noisy_Dataset()
noisy_trainloader = torch.utils.data.DataLoader(noisy_dataset, batch_size=batch_size, shuffle=True)
新たなモデルの学習
作成した敵対的サンプルのデータセットを用いて、新たにモデルを学習します。
学習に使うデータセットを変えた以外は、先ほどの学習コードと同様の内容です。
model_2 = torch.hub.load('pytorch/vision:v0.10.0', 'resnet50', pretrained=True)
model_2.fc = nn.Linear(2048, 10)
model_2.to(device)
optimizer_2 = optim.Adam(model_2.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
for epoch in tqdm(range(num_epoch)):
running_loss = 0.0
for data in noisy_trainloader:
X, y_true = data
X = X.to(device)
y_true = y_true.to(device)
optimizer_2.zero_grad()
y_pred = model_2(X)
loss = criterion(y_pred, y_true)
loss.backward()
optimizer_2.step()
running_loss += loss.item()
print(f'[{epoch + 1}] loss: {running_loss / len(noisy_trainloader):.3f}')
running_loss = 0.0
得られたモデルを使って、オリジナルのテストデータで評価してみます。
classes = ('plane', 'car', 'bird', 'cat',
'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
correct_pred = {classname: 0 for classname in classes}
total_pred = {classname: 0 for classname in classes}
with torch.no_grad():
for data in tqdm(testloader):
images, labels = data
images = images.to(device)
labels = labels.to(device)
outputs = model_2(images)
_, predictions = torch.max(outputs, 1)
for label, prediction in zip(labels, predictions):
if label == prediction:
correct_pred[classes[label]] += 1
total_pred[classes[label]] += 1
sum_accuracy = 0
for classname, correct_count in correct_pred.items():
accuracy = 100 * float(correct_count) / total_pred[classname]
print(f'Accuracy for class: {classname:5s} is {accuracy:.1f} %')
sum_accuracy += accuracy
print("---")
print(f"Total Accuracy: {sum_accuracy / len(classes):.1f} %")
Accuracy for class: plane is 27.7 %
Accuracy for class: car is 39.0 %
Accuracy for class: bird is 18.3 %
Accuracy for class: cat is 18.2 %
Accuracy for class: deer is 38.3 %
Accuracy for class: dog is 49.5 %
Accuracy for class: frog is 43.7 %
Accuracy for class: horse is 9.5 %
Accuracy for class: ship is 13.8 %
Accuracy for class: truck is 37.3 %
---
Total Accuracy: 29.5 %
高い精度とは言えませんが、ランダムな予測よりは有意に高い精度が出ているように見えます。また、ロバスト特徴量に引っ張られた予測をしているわけでもなさそうです。
このことから、非ロバスト特徴量を活用して、正しい予測が一定できていることが確認できました。
得られた敵対的サンプルの確認
作成した敵対的サンプルを元の画像と見比べてみます。今回、標準化された状態でノイズの付与を行っているので、逆変換をする関数を定義しておきます。
def reverse_normalize(img_tensor):
tensor = img_tensor.clone()
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]
for t, m, s in zip(tensor, mean, std):
t.mul_(s).add_(m) # × std + mean
return tensor
冒頭8個の画像を表示してみます。
img_arr = []
for i in range(8):
img_arr.append(reverse_normalize(trainset[i][0]))
img_arr.append(reverse_normalize(noisy_dataset.data[i]))
grid_img = vutils.make_grid(img_arr, nrow=4, padding=2)
plt.figure(figsize=(6,6))
plt.imshow(grid_img.permute(1, 2, 0))
各ペアに対して、左が元画像、右がノイズを付与した画像になります。
おそらく多くの方の目にはほとんど違いは認識できないでしょう。
今回、人間の目にはほとんど違いがわからない程度のノイズに抑えましたが、人間の目から見てクラスは明らかに変わっていないが、見比べると画像としてはちょっと違う程度のノイズの大きさまで許容するのであれば、もう少し性能は伸びると思います。
補足になりますが、今回の実験は論文の主張を確認するという意味では十分だと思いますが、細かい部分では、厳密でない実装をしています。
今回、オリジナルの$32\times32$の画像ではなく、リサイズ・クロップをして、標準化を行なった後の$224\times224$の画像にノイズを加えています。そのため、オリジナルの画像に対するノイズのノルムとはスケールがやや異なったものになっています。
まとめ
敵対的サンプルで構成された人間の目からは違和感のあるラベル付けのされたデータセットからでも、深層学習モデルは非ロバスト特徴量をしっかりと読み取り、一定の予測ができることが確認できました。
理屈としては納得していたつもりですが、実際に自分で実装してみて、結果を見てみると、本当にできるんだ、と少し驚きを感じる部分もありました。
今回の実験からも分かる通り、深層学習モデルは人間には容易に理解できない情報も使って識別を行なっています。今回の実験では画像を用いていますが、これは画像に限った話ではないと考えられます。深層学習モデルは往々にしてブラックボックスであることが多いため、細かい挙動を完全に理解することは困難だと思いますが、こういった挙動があることを知っておくというのがまずは一つ重要なのではないかと感じます。
