梯度攻击

模型攻击主要指人为地制造干扰迷惑模型,使之产生错误的结果。随着深度学习模型的广泛使用,人们发现它很容易被数据的轻微扰动所欺骗,于是开始寻找更加有效的攻击方法,针对攻击又有对抗攻击的方法,二者相互推进,不仅加强了模型的健壮性,有时还能提升模型的准确度。

原理

想让攻击更加有效,导致模型分类错误,也就是使损失函数的值变大。正常训练模型时,输入 x 是固定的,标签 y 也是固定的,通过训练调整分类模型的参数 w,使损失函数逐渐变小。而梯度攻击的分类模型参数 w 不变(分类逻辑不变),y 也固定不变,若希望损失函数值变大,就只能修改输入。下面就来看看如何利用梯度方法修改输入数据。

FGSM

FGSM 是比较早期的梯度攻击算法,源于 2015 年的论文《EXPLAINING  AND HARNESSING ADVERSARIAL EXAMPLES》,论文地址:https://arxiv.org/pdf/1412.6572.pdf。FGSM 全称是 Fast Gradient Sign Method 快速梯度下降法。其原理是求模型误差函数对输入的导数,然后用符号函数得到其梯度方向,并乘以一个步长ε,将得到的“扰动”加在原来的输入数据之上就得到了攻击样本。其公式如下:

其中 L 是损失函数,θ是模型参数,x 是输入,x’是扰动后的输入,y 是输出,sgn 是符号函数,ε为步长。由于只对数据做一次扰动,计算速度非常快,因此有 Fast,而累加项是梯度方向,而不是梯度本身,因此有 Sign,此种添加扰动的方法就称为 Fast Gradient Sign Method。

2017 年,FGSM 的作者又提出了 FGM,对 FGSM 中符号函数部分做了一些修改,以便攻击文本。

PGD

PGD 是 FGSM 的改进版本,它源于 2018 年的论文《Towards Deep Learning Models Resistant to Adversarial Attacks》,论文地址:https://arxiv.org/pdf/1706.06083.pdf

FGSM 从始至终只做了一次修改,改动的大小依赖步长ε,如果步长太大,则原数据被改得面目全非,如果改动太小又无法骗过模型。一般做干扰的目的是保持数据原始的性质,只为骗过模型,而非完全替换数据。如下图所示,当做了一个步长很大的扰动之后,原图直接变成了另一张图,不但机器无法辨认,人也无法辨认。

为了让扰动更加小而有效,出现了迭代修改的方法 PGD,每次进行少量修改,扰动多次,这样既避免了抖动过大,同时多次迭代又能在多个方向上修改复杂模型。

其公式如下:

注意等式右边的∏在这里不是连乘符号,它保证扰动过程中的数据 x 始终处于限制范围之内(若过大则裁剪)。此公式与类似 FGSM 类似,其中的α是每次修改的步长,而每一次修改的 xt+1都基于其前一次修改 xt。论文中提到,PGD 攻击是最强的一阶攻击,由于相对复杂,PGD 也需要更多的时间和算力。

例程

以上两种算法原理都非常简单,而实际操作中,比较困难的是如何对输入求取梯度。下面使用识别 Mnist 手写体数据集为例展示攻击效果,先训练一个识别率在 95% 以上的模型,然后分别用两种方法加入扰动,并分析其识别率的变化。例程分为三部分:训练分类模型、攻击、做图。

训练分类模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from torch.autograd import Variable
import numpy as np
import matplotlib.pyplot as plt

class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
self.conv2_drop = nn.Dropout2d()
self.fc1 = nn.Linear(320, 50)
self.fc2 = nn.Linear(50, 10)

def forward(self, x):
x = F.relu(F.max_pool2d(self.conv1(x), 2))
x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
x = x.view(-1, 320)
x = F.relu(self.fc1(x))
x = F.dropout(x, training=self.training)
x = self.fc2(x)
return x

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') #启用GPU
train_loader = torch.utils.data.DataLoader( # 加载训练数据
datasets.MNIST('datasets', train=True, download=True,
transform=transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])),
batch_size=64, shuffle=True)

model = Net()
model = model.to(device)
optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.5) # 初始化优化器

for epoch in range(1, 10 + 1): # 共迭代10次
for batch_idx, (data, target) in enumerate(train_loader):
data = data.to(device)
target = target.to(device)
data, target = Variable(data), Variable(target)
optimizer.zero_grad()
output = model(data) # 代入模型
loss = F.cross_entropy(output,target)
loss.backward()
optimizer.step()
if batch_idx % 100 == 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()))

torch.save(model, 'datasets/model.pth') #保存模型

攻击:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
USE_PGD = False

def draw(data):
ex = data.squeeze().detach().cpu().numpy()
plt.imshow(ex, cmap="gray")
plt.show()

def test(model, device, test_loader, epsilon, t = 5, debug = False):
correct = 0
adv_examples = []

for data, target in test_loader:
data, target = data.to(device), target.to(device)
data.requires_grad = True # 以便对输入求导 ** 重要 **
output = model(data)
init_pred = output.max(1, keepdim=True)[1]
if init_pred.item() != target.item(): # 如果不扰动也预测不对,则跳过
continue
if debug:
draw(data)

if USE_PGD:
alpha = epsilon / t # 每次只改变一小步
perturbed_data = data
final_pred = init_pred
#while target.item() == final_pred.item(): # 只要修改成功就退出
for i in range(t): # 共迭代 t 次
if debug:
print("target", target.item(), "pred", final_pred.item())
loss = F.cross_entropy(output, target)
model.zero_grad()
loss.backward(retain_graph=True)
data_grad = data.grad.data # 输入数据的梯度 ** 重要 **

sign_data_grad = data_grad.sign() # 取符号(正负)
perturbed_image = perturbed_data + alpha * sign_data_grad # 添加扰动
perturbed_data = torch.clamp(perturbed_image, 0, 1) # 把各元素压缩到[0,1]之间

output = model(perturbed_data) # 代入扰动后的数据
final_pred = output.max(1, keepdim=True)[1] # 预测选项
if debug:
draw(perturbed_data)
else:
loss = F.cross_entropy(output, target)
model.zero_grad()
loss.backward()

data_grad = data.grad.data # 输入数据的梯度 ** 重要 **
sign_data_grad = data_grad.sign() # 取符号(正负)
perturbed_image = data + epsilon*sign_data_grad # 添加扰动
perturbed_data = torch.clamp(perturbed_image, 0, 1) # 把各元素压缩到[0,1]之间

output = model(perturbed_data) # 代入扰动后的数据
final_pred = output.max(1, keepdim=True)[1]

# 统计准确率并记录,以便后面做图
if final_pred.item() == target.item():
correct += 1
if (epsilon == 0) and (len(adv_examples) < 5):
adv_ex = perturbed_data.squeeze().detach().cpu().numpy()
adv_examples.append((init_pred.item(), final_pred.item(), adv_ex))
else: # 保存扰动后错误分类的图片
if len(adv_examples) < 5:
adv_ex = perturbed_data.squeeze().detach().cpu().numpy()
adv_examples.append((init_pred.item(), final_pred.item(), adv_ex))

final_acc = correct / float(len(test_loader)) # 计算整体准确率
print("Epsilon: {}\tTest Accuracy = {} / {} = {}".format(epsilon, correct, len(test_loader), final_acc))
return final_acc, adv_examples

epsilons = [0, .05, .1, .15, .2, .25, .3] # 使用不同的调整力度
pretrained_model = "datasets/model.pth" # 使用的预训练模型路径

test_loader = torch.utils.data.DataLoader(
datasets.MNIST('datasets', train=False, download=True, transform=transforms.Compose([
transforms.ToTensor(),
])),
batch_size=1, shuffle=True
)
model = torch.load(pretrained_model, map_location='cpu').to(device)
model.eval()

accuracies = []
examples = []
for eps in epsilons: # 每次测一种超参数
acc, ex = test(model, device, test_loader, eps)
accuracies.append(acc)
examples.append(ex)

做图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 做图
plt.figure(figsize=(8,5))
plt.plot(epsilons, accuracies, "*-")
plt.yticks(np.arange(0, 1.1, step=0.1))
plt.xticks(np.arange(0, .35, step=0.05))
plt.title("Accuracy vs Epsilon")
plt.xlabel("Epsilon")
plt.ylabel("Accuracy")
plt.show()

cnt = 0
plt.figure(figsize=(8,10))
for i in range(len(epsilons)):
for j in range(len(examples[i])):
cnt += 1
plt.subplot(len(epsilons),len(examples[0]),cnt)
plt.xticks([], [])
plt.yticks([], [])
if j == 0:
plt.ylabel("Eps: {}".format(epsilons[i]), fontsize=14)
orig,adv,ex = examples[i][j]
plt.title("{} -> {}".format(orig, adv))
plt.imshow(ex, cmap="gray")
plt.tight_layout()
plt.show()

从例程运行结果可以看到,同样的改动大小ε,PGD 明显比 FGSM 效果好,这让开发者使用更小的改动达到更好的效果,当然,PGD 速度也比较慢。

用以上方法得到的不是一个神经网络模型,而是根据现有模型计算出来的,对单个实例的调整方法和结果。

本例是对图片分类模型的攻击,攻击文字模型时,主要是在 Embedding 层上添加扰动,涉及 Token 与 Embedded 转换问题,可以借助 gensim 等工具将梯度调整后的 Embedded 转换成文字。