PyTorch 入门指南
学习 PyTorch
图像和视频
音频
后端
强化学习
在生产环境中部署 PyTorch 模型
Profiling PyTorch
代码变换与FX
前端API
扩展 PyTorch
模型优化
并行和分布式训练
边缘端的 ExecuTorch
推荐系统
多模态

对抗样本生成

作者: Nathan Inkawhich

如果您正在阅读本文,希望您能体会到某些机器学习模型的强大之处。研究不断推动着机器学习模型朝着更快、更准确、更高效的方向发展。然而,在设计和训练模型时,一个经常被忽视的方面是安全性和鲁棒性,尤其是在面对试图欺骗模型的对手时。

本教程将提高您对机器学习模型安全漏洞的认识,并深入探讨对抗性机器学习这一热门话题。您可能会惊讶地发现,对图像添加几乎不可察觉的扰动可能会导致模型性能的显著不同。由于这是一个教程,我们将通过图像分类器的示例来探讨这一主题。具体来说,我们将使用最早也是最流行的攻击方法之一——快速梯度符号攻击(FGSM),来欺骗一个MNIST分类器。

威胁模型

在对抗攻击的背景下,攻击有多种类别,每种攻击都有不同的目标和攻击者知识的假设。然而,总体目标通常是在输入数据中添加最少的扰动,以导致期望的错误分类。关于攻击者知识的假设有几种,其中两种是:白盒攻击黑盒攻击白盒攻击假设攻击者拥有对模型的完全了解和访问权限,包括模型架构、输入、输出和权重。黑盒攻击假设攻击者只能访问模型的输入和输出,而对底层架构或权重一无所知。攻击的目标也有几种类型,包括错误分类源/目标错误分类错误分类的目标意味着攻击者只希望输出分类错误,而不关心新的分类是什么。源/目标错误分类意味着攻击者希望将原本属于特定源类的图像修改为被分类为特定目标类。

在这种情况下,FGSM攻击是一种以错误分类为目标的白盒攻击。有了这些背景信息,我们现在可以详细讨论这种攻击。

快速梯度符号攻击

迄今为止,最早且最流行的对抗攻击之一被称为快速梯度符号攻击(FGSM),由 Goodfellow 等人在 Explaining and Harnessing Adversarial Examples 中描述。这种攻击非常强大,同时也非常直观。它旨在利用神经网络学习的方式,即梯度,来攻击神经网络。其核心思想很简单,不是通过基于反向传播的梯度调整权重来最小化损失,而是基于相同的反向传播梯度调整输入数据以最大化损失。换句话说,攻击使用损失相对于输入数据的梯度,然后调整输入数据以最大化损失。

在深入代码之前,让我们先来看看著名的 FGSM 熊猫示例,并提取一些符号。

fgsm_panda_image

从图中可以看出,\(\mathbf{x}\) 是被正确分类为“熊猫”的原始输入图像,\(y\) 是 \(\mathbf{x}\) 的真实标签,\(\mathbf{\theta}\) 表示模型参数,\(J(\mathbf{\theta}, \mathbf{x}, y)\) 是用于训练网络的损失函数。攻击通过将梯度反向传播到输入数据来计算 \(\nabla_{x} J(\mathbf{\theta}, \mathbf{x}, y)\)。然后,它沿着能够最大化损失的方向(即 \(sign(\nabla_{x} J(\mathbf{\theta}, \mathbf{x}, y))\))对输入数据进行小幅调整(图中的 \(\epsilon\) 或 \(0.007\))。生成的扰动图像 \(x'\) 随后被目标网络错误分类为“长臂猿”,而它实际上仍然明显是一只“熊猫”。

希望现在您对本教程的动机已经清楚,接下来让我们直接进入实现部分。

importtorch
importtorch.nnasnn
importtorch.nn.functionalasF
importtorch.optimasoptim
fromtorchvisionimport datasets, transforms
importnumpyasnp
importmatplotlib.pyplotasplt

实现

在本节中,我们将讨论教程的输入参数,定义被攻击的模型,然后编写攻击代码并运行一些测试。

输入

本教程仅包含三个输入,定义如下:

  • epsilons - 用于运行的 epsilon 值列表。在列表中保留 0 非常重要,因为它代表模型在原始测试集上的性能。此外,直观上我们会期望 epsilon 值越大,扰动越明显,但攻击在降低模型准确性方面也越有效。由于这里的数据范围是 \([0,1]\),所以 epsilon 值不应超过 1。

  • pretrained_model - 预训练 MNIST 模型的路径,该模型是在 pytorch/examples/mnist 上训练的。为简化操作,可以在此处下载预训练模型 here

epsilons = [0, .05, .1, .15, .2, .25, .3]
pretrained_model = "data/lenet_mnist_model.pth"
# Set random seed for reproducibility
torch.manual_seed(42)
<torch._C.Generator object at 0x7f3682196390>

受攻击的模型

如前所述,被攻击的模型与 pytorch/examples/mnist 中的 MNIST 模型相同。您可以训练并保存自己的 MNIST 模型,也可以下载并使用提供的模型。这里的 Net 定义和测试数据加载器是从 MNIST 示例中复制的。本节的目的是定义模型和数据加载器,然后初始化模型并加载预训练的权重。

# LeNet Model definition
classNet(nn.Module):
    def__init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 32, 3, 1)
        self.conv2 = nn.Conv2d(32, 64, 3, 1)
        self.dropout1 = nn.Dropout(0.25)
        self.dropout2 = nn.Dropout(0.5)
        self.fc1 = nn.Linear(9216, 128)
        self.fc2 = nn.Linear(128, 10)

    defforward(self, x):
        x = self.conv1(x)
        x = F.relu(x)
        x = self.conv2(x)
        x = F.relu(x)
        x = F.max_pool2d(x, 2)
        x = self.dropout1(x)
        x = torch.flatten(x, 1)
        x = self.fc1(x)
        x = F.relu(x)
        x = self.dropout2(x)
        x = self.fc2(x)
        output = F.log_softmax(x, dim=1)
        return output

# MNIST Test dataset and dataloader declaration
test_loader = torch.utils.data.DataLoader(
    datasets.MNIST('../data', train=False, download=True, transform=transforms.Compose([
            transforms.ToTensor(),
            transforms.Normalize((0.1307,), (0.3081,)),
            ])),
        batch_size=1, shuffle=True)

# We want to be able to train our model on an `accelerator <https://pytorch.org/docs/stable/torch.html#accelerators>`__
# such as CUDA, MPS, MTIA, or XPU. If the current accelerator is available, we will use it. Otherwise, we use the CPU.
device = torch.accelerator.current_accelerator().type if torch.accelerator.is_available() else "cpu"
print(f"Using {device} device")

# Initialize the network
model = Net().to(device)

# Load the pretrained model
model.load_state_dict(torch.load(pretrained_model, map_location=device, weights_only=True))

# Set the model in evaluation mode. In this case this is for the Dropout layers
model.eval()
  0%|          | 0.00/9.91M [00:00<?, ?B/s]
100%|##########| 9.91M/9.91M [00:00<00:00, 149MB/s]

  0%|          | 0.00/28.9k [00:00<?, ?B/s]
100%|##########| 28.9k/28.9k [00:00<00:00, 52.4MB/s]

  0%|          | 0.00/1.65M [00:00<?, ?B/s]
100%|##########| 1.65M/1.65M [00:00<00:00, 166MB/s]

  0%|          | 0.00/4.54k [00:00<?, ?B/s]
100%|##########| 4.54k/4.54k [00:00<00:00, 19.4MB/s]
Using cuda device

Net(
  (conv1): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1))
  (conv2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1))
  (dropout1): Dropout(p=0.25, inplace=False)
  (dropout2): Dropout(p=0.5, inplace=False)
  (fc1): Linear(in_features=9216, out_features=128, bias=True)
  (fc2): Linear(in_features=128, out_features=10, bias=True)
)

FGSM 攻击

现在,我们可以定义通过扰动原始输入来创建对抗样本的函数。fgsm_attack 函数接收三个输入,image 是原始的干净图像 (\(x\)),epsilon 是像素级的扰动量 (\(\epsilon\)),而 data_grad 是损失相对于输入图像的梯度 (\(\nabla_{x} J(\mathbf{\theta}, \mathbf{x}, y)\))。该函数随后创建扰动后的图像,具体如下:

\[perturbed\_image = image + epsilon*sign(data\_grad) = x + \epsilon * sign(\nabla_{x} J(\mathbf{\theta}, \mathbf{x}, y)) \]

最后,为了保持数据的原始范围,扰动后的图像会被裁剪到范围 \([0,1]\)。

# FGSM attack code
deffgsm_attack(image, epsilon, data_grad):
    # Collect the element-wise sign of the data gradient
    sign_data_grad = data_grad.sign()
    # Create the perturbed image by adjusting each pixel of the input image
    perturbed_image = image + epsilon*sign_data_grad
    # Adding clipping to maintain [0,1] range
    perturbed_image = torch.clamp(perturbed_image, 0, 1)
    # Return the perturbed image
    return perturbed_image

# restores the tensors to their original scale
defdenorm(batch, mean=[0.1307], std=[0.3081]):
"""
    Convert a batch of tensors to their original scale.

    Args:
        batch (torch.Tensor): Batch of normalized tensors.
        mean (torch.Tensor or list): Mean used for normalization.
        std (torch.Tensor or list): Standard deviation used for normalization.

    Returns:
        torch.Tensor: batch of tensors without normalization applied to them.
    """
    if isinstance(mean, list):
        mean = torch.tensor(mean).to(device)
    if isinstance(std, list):
        std = torch.tensor(std).to(device)

    return batch * std.view(1, -1, 1, 1) + mean.view(1, -1, 1, 1)

测试函数

最后,本教程的核心结果来自 test 函数。每次调用此测试函数都会在 MNIST 测试集上执行完整的测试步骤,并报告最终的准确率。然而,需要注意的是,该函数还接受一个 epsilon 输入。这是因为 test 函数报告的是模型在受到强度为 \(\epsilon\) 的对抗攻击下的准确率。更具体地说,对于测试集中的每个样本,该函数计算损失相对于输入数据的梯度 (\(data\_grad\)),使用 fgsm_attack 创建扰动图像 (\(perturbed\_data\)),然后检查扰动后的样本是否具有对抗性。除了测试模型的准确率外,该函数还会保存并返回一些成功的对抗样本,以便后续进行可视化。

deftest( model, device, test_loader, epsilon ):

    # Accuracy counter
    correct = 0
    adv_examples = []

    # Loop over all examples in test set
    for data, target in test_loader:

        # Send the data and label to the device
        data, target = data.to(device), target.to(device)

        # Set requires_grad attribute of tensor. Important for Attack
        data.requires_grad = True

        # Forward pass the data through the model
        output = model(data)
        init_pred = output.max(1, keepdim=True)[1] # get the index of the max log-probability

        # If the initial prediction is wrong, don't bother attacking, just move on
        if init_pred.item() != target.item():
            continue

        # Calculate the loss
        loss = F.nll_loss(output, target)

        # Zero all existing gradients
        model.zero_grad()

        # Calculate gradients of model in backward pass
        loss.backward()

        # Collect ``datagrad``
        data_grad = data.grad.data

        # Restore the data to its original scale
        data_denorm = denorm(data)

        # Call FGSM Attack
        perturbed_data = fgsm_attack(data_denorm, epsilon, data_grad)

        # Reapply normalization
        perturbed_data_normalized = transforms.Normalize((0.1307,), (0.3081,))(perturbed_data)

        # Re-classify the perturbed image
        output = model(perturbed_data_normalized)

        # Check for success
        final_pred = output.max(1, keepdim=True)[1] # get the index of the max log-probability
        if final_pred.item() == target.item():
            correct += 1
            # Special case for saving 0 epsilon examples
            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:
            # Save some adv examples for visualization later
            if len(adv_examples) < 5:
                adv_ex = perturbed_data.squeeze().detach().cpu().numpy()
                adv_examples.append( (init_pred.item(), final_pred.item(), adv_ex) )

    # Calculate final accuracy for this epsilon
    final_acc = correct/float(len(test_loader))
    print(f"Epsilon: {epsilon}\tTest Accuracy = {correct} / {len(test_loader)} = {final_acc}")

    # Return the accuracy and an adversarial example
    return final_acc, adv_examples

执行攻击

实现的最后一部分是实际运行攻击。在这里,我们为 epsilons 输入中的每个 epsilon 值运行完整的测试步骤。对于每个 epsilon,我们还保存了最终的准确率和一些成功的对抗样本,以便在接下来的部分中进行绘制。请注意,随着 epsilon 值的增加,打印出的准确率是如何下降的。此外,请注意 \(\epsilon=0\) 的情况代表了未经攻击的原始测试准确率。

accuracies = []
examples = []

# Run test for each epsilon
for eps in epsilons:
    acc, ex = test(model, device, test_loader, eps)
    accuracies.append(acc)
    examples.append(ex)
Epsilon: 0      Test Accuracy = 9912 / 10000 = 0.9912
Epsilon: 0.05   Test Accuracy = 9605 / 10000 = 0.9605
Epsilon: 0.1    Test Accuracy = 8743 / 10000 = 0.8743
Epsilon: 0.15   Test Accuracy = 7111 / 10000 = 0.7111
Epsilon: 0.2    Test Accuracy = 4877 / 10000 = 0.4877
Epsilon: 0.25   Test Accuracy = 2717 / 10000 = 0.2717
Epsilon: 0.3    Test Accuracy = 1418 / 10000 = 0.1418

结果

准确率 vs Epsilon

第一个结果是准确率与 epsilon 的关系图。正如前面提到的,随着 epsilon 的增加,我们预计测试准确率会下降。这是因为较大的 epsilon 意味着我们在最大化损失的方向上迈出了更大的步长。请注意,曲线中的趋势并不是线性的,即使 epsilon 值是线性间隔的。例如,\(\epsilon=0.05\) 时的准确率仅比 \(\epsilon=0\) 时低约 4%,但 \(\epsilon=0.2\) 时的准确率比 \(\epsilon=0.15\) 时低 25%。此外,注意到模型的准确率在 \(\epsilon=0.25\) 到 \(\epsilon=0.3\) 之间达到了 10 类分类器的随机准确率。

plt.figure(figsize=(5,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()

Accuracy vs Epsilon

对抗样本示例

还记得“天下没有免费的午餐”这个说法吗?在这种情况下,随着 epsilon 的增加,测试精度会下降扰动变得更容易被察觉。实际上,攻击者必须在精度下降和可感知性之间做出权衡。这里,我们展示了在每个 epsilon 值下成功生成的对抗样本示例。图中的每一行代表不同的 epsilon 值。第一行是 \(\epsilon=0\) 的示例,表示未添加扰动的原始“干净”图像。每张图像的标题显示“原始分类 -> 对抗分类”。注意,扰动在 \(\epsilon=0.15\) 时开始显现,并在 \(\epsilon=0.3\) 时变得非常明显。然而,在所有情况下,尽管添加了噪声,人类仍然能够识别出正确的类别。

# Plot several examples of adversarial samples at each epsilon
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(f"Eps: {epsilons[i]}", fontsize=14)
        orig,adv,ex = examples[i][j]
        plt.title(f"{orig} -> {adv}")
        plt.imshow(ex, cmap="gray")
plt.tight_layout()
plt.show()

7 -> 7, 9 -> 9, 0 -> 0, 3 -> 3, 5 -> 5, 2 -> 8, 1 -> 3, 3 -> 5, 4 -> 6, 4 -> 9, 9 -> 4, 5 -> 6, 9 -> 5, 9 -> 5, 3 -> 2, 3 -> 5, 5 -> 3, 1 -> 6, 4 -> 9, 7 -> 9, 7 -> 2, 8 -> 2, 4 -> 8, 3 -> 7, 5 -> 3, 8 -> 3, 0 -> 8, 6 -> 5, 2 -> 3, 1 -> 8, 1 -> 9, 1 -> 8, 5 -> 8, 7 -> 8, 0 -> 2

下一步该做什么?

希望本教程能为您提供一些关于对抗性机器学习的见解。从这里出发,有许多潜在的研究方向。这次攻击代表了对抗性攻击研究的开端,自那以后,已经出现了许多关于如何攻击和防御机器学习模型的新思路。事实上,在 NIPS 2017 上举办了一次对抗性攻击和防御竞赛,竞赛中使用的方法在这篇论文中有所描述:对抗性攻击和防御竞赛。防御方面的研究也引出了一个更广泛的想法,即让机器学习模型在面对自然扰动和对抗性输入的攻击时变得更加鲁棒

另一个研究方向是不同领域中的对抗攻击与防御。对抗研究不仅限于图像领域,可以看看这篇对语音转文本模型的攻击示例。不过,深入了解对抗机器学习的最佳方式或许是亲自动手实践。尝试实现 NIPS 2017 竞赛中的一种不同攻击方法,看看它与 FGSM 有何不同。然后,尝试防御模型免受自己攻击的影响。

根据可用资源,进一步的研究方向是修改代码以支持批量处理、并行处理或分布式处理,而不是在每次 epsilon test() 循环中一次只处理一个攻击。

本页目录