AI鲁棒性:噪声与对抗防御笔记

别急着给AI鲁棒性下定义,先看这次卡在哪。

这篇不讲理论,主要记录在做 AI 鲁棒性时踩过的坑和试过的方案。

从一次翻车说起

先说个真实案例。当时给客户做了一个图像分类服务,测试集准确率 98.7%,内部测试也都没问题。上线三天,客服反馈识别错误率突然上升。查了半天发现,那段时间用户上传的图片大多经过社交软件压缩,噪点、模糊、色偏都比预期严重得多。

原本训练数据大多是高清原始图,生产环境遇到的却是各种压缩、裁剪、滤镜处理过的"脏"数据。模型在干净数据上学到的特征,在噪声环境下完全失效。

# 当时测的一组对比,同一张图经过不同程度的压缩干扰
from PIL import Image
import numpy as np
import tensorflow as tf

model = tf.keras.models.load_model('classification_model.h5')

# 原始图
img_original = Image.open('test_image.jpg').resize((224, 224))
pred_original = model.predict(np.array(img_original)[np.newaxis, ...])
print(f"原始图预测置信度: {np.max(pred_original):.4f}")

# JPEG 压缩质量 30
img_quality_30 = Image.open('test_image.jpg')
img_quality_30.save('compressed_30.jpg', quality=30)
img_compressed = Image.open('compressed_30.jpg').resize((224, 224))
pred_compressed = model.predict(np.array(img_compressed)[np.newaxis, ...])
print(f"压缩质量30预测置信度: {np.max(pred_compressed):.4f}")

# 实际输出
# 原始图预测置信度: 0.9923
# 压缩质量30预测置信度: 0.6124

从 99% 跌到 61%,置信度差距超过 38 个百分点。这才意识到,训练时完全不碰的"干净环境"假设,在生产中根本站不住脚。

噪声不是小事

很多人觉得噪声处理就是加几层高斯噪声、随机丢点像素就行。实际做下来发现,不同场景的噪声特性差异很大,统一加"通用噪声"往往效果有限。

最常见的是下面几种:

压缩噪声:JPEG 压缩产生的块状失真,边缘会出现棋盘格纹理。这东西特别讨厌,因为它会破坏高频特征,而很多边缘检测和纹理识别都依赖这些信息。

光照变化:暗光环境下信噪比下降,相机自动增益会引入额外噪声。不同设备、不同算法的噪声模式也不一样。

传输噪声:网络传输丢包、降采样、编码器差异,这些都是训练时不会考虑但生产中常见的问题。

当时试过最笨的办法:人工制造各种噪声样本重新训练。

import albumentations as A

# 手写的一套数据增强策略,针对性模拟真实环境噪声
transform = A.Compose([
    A.RandomBrightnessContrast(brightness_limit=0.3, contrast_limit=0.3, p=0.5),
    A.GaussNoise(var_limit=(10.0, 50.0), p=0.5),
    A.GaussianBlur(blur_limit=(3, 7), p=0.4),
    A.MotionBlur(blur_limit=5, p=0.3),
    A.ImageCompression(quality_lower=30, quality_upper=95, p=0.6),
    A.ISONoise(color_shift=(0.01, 0.05), intensity=(0.1, 0.5), p=0.4),
    A.Downscale(scale_min=0.5, scale_max=0.9, p=0.3),
    A.HueSaturationValue(hue_shift_limit=20, sat_shift_limit=30, val_shift_limit=20, p=0.4),
])

def augment_image(img_path):
    img = Image.open(img_path)
    img_array = np.array(img)
    augmented = transform(image=img_array)
    return Image.fromarray(augmented['image'])

这么折腾了一轮,模型对压缩噪声的抵抗能力确实提高了,准确率从 61% 涨回 87%。但也带来一个新问题:模型在干净数据上的性能略有下降,从 99% 降到 96%。

这就到了第一个纠结点:鲁棒性和性能的权衡

要不要为了抗噪牺牲一点准确率?当时觉得值,因为生产环境的容错要求比测试集严得多。现在回头想,这种"宁可错杀不可放过"的策略,对某些场景(比如医疗影像)可能就是灾难。这里没有标准答案,完全取决于应用场景的风险偏好。

对抗样本才是真刺客

噪声问题刚稳定没多久,又碰到了更狠的:对抗样本。

第一次见识这东西是看 FGSM 的演示,一张猫的图加一点点人眼看不出差异的扰动,模型就信誓旦旦说是狗。当时觉得这只是个玩具 demo,直到自己项目里碰到。

import torch
import torch.nn as nn
import torchvision.models as models
import torchvision.transforms as transforms

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# 加载一个预训练的 ResNet18
model = models.resnet18(pretrained=True)
model = model.to(device)
model.eval()

preprocess = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
])

def fgsm_attack(image, epsilon, data_grad):
    # 收集数据梯度的元素符号
    sign_data_grad = data_grad.sign()
    # 通过调整输入图像的每个像素来创建扰动图像
    perturbed_image = image + epsilon * sign_data_grad
    # 添加剪裁以保持 [0,1] 范围
    perturbed_image = torch.clamp(perturbed_image, 0, 1)
    return perturbed_image

# 加载一张测试图片
image = Image.open('cat.jpg')
image_tensor = preprocess(image).unsqueeze(0).to(device)
image_tensor.requires_grad = True

output = model(image_tensor)
original_pred = torch.argmax(output).item()

# 计算 loss
target = torch.tensor([281], device=device) # ImageNet 里的"tabby cat"类别
loss = nn.CrossEntropyLoss()(output, target)

# 计算梯度
loss.backward()
data_grad = image_tensor.grad.data

# 应用 FGSM 攻击,epsilon=0.007
perturbed_image = fgsm_attack(image_tensor, 0.007, data_grad)
output_perturbed = model(perturbed_image)
perturbed_pred = torch.argmax(output_perturbed).item()

print(f"原始预测类别: {original_pred}")
print(f"对抗后预测类别: {perturbed_pred}")
# 实际输出
# 原始预测类别: 281 (tabby cat)
# 对抗后预测类别: 263 ( Pembroke Welsh Corgi)

一张肉眼几乎看不出差别的图,模型就从「猫」判成「柯基」——它抓的是人眼忽略的脆弱特征。

试了几个对抗攻击算法,FGSM 最简单,PGD 更狠,C&W 难度最大但效果也最隐蔽。

# PGD 攻击示例
def pgd_attack(model, image, target, epsilon=0.03, alpha=0.007, num_iter=10):
    perturbed_image = image.clone().detach()
    perturbed_image = torch.clamp(perturbed_image + torch.empty_like(perturbed_image).uniform_(-epsilon, epsilon), 0, 1)

    for _ in range(num_iter):
        perturbed_image.requires_grad = True
        output = model(perturbed_image)
        loss = nn.CrossEntropyLoss()(output, target)

        model.zero_grad()
        loss.backward()

        data_grad = perturbed_image.grad.data
        perturbed_image = perturbed_image + alpha * data_grad.sign()
        perturbed_image = torch.clamp(perturbed_image, image - epsilon, image + epsilon)
        perturbed_image = torch.clamp(perturbed_image, 0, 1).detach()

    return perturbed_image

# 试用 PGD
pgd_image = pgd_attack(model, image_tensor, target, epsilon=0.03, alpha=0.007, num_iter=40)
output_pgd = model(pgd_image)
pgd_pred = torch.argmax(output_pgd).item()

print(f"PGD 对抗后预测类别: {pgd_pred}")
# 在 epsilon=0.03, num_iter=40 的条件下,成功率接近 100%

PGD 是迭代式的 FGSM,多跑几轮效果会更好,但计算量也更大。在实际测试中,ε=0.03 时,PGD 几乎可以把所有样本都攻击成功。

鲁棒训练的坑

知道有问题,就要想办法防。当时试了三个方向:对抗训练、输入防御、模型结构改进。

对抗训练最直接,就是在训练数据里混入对抗样本,让模型学会"抗干扰"。

# 对抗训练的一个简化实现
def adversarial_train(model, train_loader, epsilon=0.007, num_epochs=10):
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    criterion = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        model.train()
        total_loss = 0

        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)
            images.requires_grad = True

            # 正常前向传播
            outputs = model(images)
            loss = criterion(outputs, labels)

            # 计算梯度并生成对抗样本
            model.zero_grad()
            loss.backward()
            data_grad = images.grad.data
            perturbed_images = fgsm_attack(images, epsilon, data_grad)

            # 用对抗样本再次训练
            outputs_adv = model(perturbed_images)
            loss_adv = criterion(outputs_adv, labels)

            optimizer.zero_grad()
            loss_adv.backward()
            optimizer.step()

            total_loss += loss_adv.item()

        print(f"Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}")

# 测试对抗训练效果
adversarial_train(model, train_loader, epsilon=0.007, num_epochs=5)

# 在测试集上评估
def test_robustness(model, test_loader, epsilon=0.007):
    model.eval()
    correct_original = 0
    correct_adv = 0
    total = 0

    with torch.no_grad():
        for images, labels in test_loader:
            images, labels = images.to(device), labels.to(device)

            # 原始样本
            outputs = model(images)
            _, predicted = torch.max(outputs, 1)
            correct_original += (predicted == labels).sum().item()

            # 生成对抗样本
            images_adv = images.clone()
            images_adv.requires_grad = True
            outputs = model(images_adv)
            loss = nn.CrossEntropyLoss()(outputs, labels)
            model.zero_grad()
            loss.backward()
            images_perturbed = fgsm_attack(images_adv, epsilon, images_adv.grad.data)

            outputs_adv = model(images_perturbed)
            _, predicted_adv = torch.max(outputs_adv, 1)
            correct_adv += (predicted_adv == labels).sum().item()

            total += labels.size(0)

    print(f"原始准确率: {100 * correct_original / total:.2f}%")
    print(f"对抗准确率: {100 * correct_adv / total:.2f}%")

test_robustness(model, test_loader, epsilon=0.007)
# 对抗训练前
# 原始准确率: 92.5%
# 对抗准确率: 12.3%
# 对抗训练 5 轮后
# 原始准确率: 89.7%
# 对抗准确率: 68.4%

结果很现实:对抗样本准确率从 12% 涨到 68%,但原始准确率从 92% 掉到 89%。这就是典型的"鲁棒性换性能"。

在训练中还踩过几个坑:

  1. ε 的选择很敏感:ε 太小对抗效果不明显,ε 太大破坏图像语义。最后试下来 ε=0.007 比较平衡。

  2. 对抗样本生成成本高:每个 batch 都要算梯度、生成对抗样本,训练时间几乎翻倍。后来改成"隔轮对抗"——隔一个 batch 用一次对抗样本,时间降下来但效果也掉了点。

  3. 过拟合对抗样本:模型会过拟合特定攻击生成的对抗样本,对新的攻击方式依然脆弱。后来改成"多策略对抗训练",FGSM、PGD、随机噪声轮着来,泛化能力好一些。

# 多策略对抗训练
def multi_strategy_adversarial_train(model, train_loader, num_epochs=10):
    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    criterion = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        model.train()
        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)

            # 随机选择攻击策略
            strategy = np.random.choice(['fgsm', 'pgd', 'gaussian', 'normal'])

            if strategy == 'fgsm':
                images_adv = generate_fgsm_samples(model, images, labels)
            elif strategy == 'pgd':
                images_adv = generate_pgd_samples(model, images, labels)
            elif strategy == 'gaussian':
                images_adv = add_gaussian_noise(images)
            else:
                images_adv = images  # 不攻击

            outputs = model(images_adv)
            loss = criterion(outputs, labels)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

多策略训练后,模型在未知攻击上的鲁棒性确实提升了,但训练成本又上去了。这就陷入了一个循环:要安全就要多训练,要效率就要牺牲安全性。

输入防御是另一个思路,在模型前面加一层防御机制,比如图像预处理、特征提取、或者专门的小模型检测对抗样本。

# 简单的输入预处理防御
def input_preprocess(image):
    # 高斯模糊
    blurred = transforms.functional.gaussian_blur(image, kernel_size=3)
    # JPEG 压缩
    to_pil = transforms.ToPILImage()
    to_tensor = transforms.ToTensor()
    pil_img = to_pil(blurred)
    compressed = io.BytesIO()
    pil_img.save(compressed, format='JPEG', quality=75)
    compressed.seek(0)
    processed = to_tensor(Image.open(compressed))

    return processed

def test_with_preprocessing(model, test_loader):
    model.eval()
    correct = 0
    total = 0

    with torch.no_grad():
        for images, labels in test_loader:
            images, labels = images.to(device), labels.to(device)

            # 生成对抗样本
            images_adv = generate_fgsm_samples(model, images, labels)

            # 预处理
            processed_images = torch.stack([input_preprocess(img) for img in images_adv])

            outputs = model(processed_images)
            _, predicted = torch.max(outputs, 1)
            correct += (predicted == labels).sum().item()
            total += labels.size(0)

    print(f"预处理后对抗准确率: {100 * correct / total:.2f}%")

预处理防御有效,但有个大问题:防御是有针对性的。高斯模糊对高频扰动有效,但会破坏高频特征本身;JPEG 压缩对块状失真有效,但会引入新的块状失真。

最麻烦的是,攻击者知道你用哪种防御,就能针对性地设计新攻击。这就是"攻防博弈"的现实:没有绝对安全的防御,只有相对较难攻破的方案。

模型结构的改进

后来也试过改模型结构,比如加一些更强的正则化、用更深的网络、或者专门设计鲁棒性结构。

# 一个加了额外正则化的模型结构
class RobustCNN(nn.Module):
    def __init__(self, num_classes=10):
        super(RobustCNN, self).__init__()

        self.features = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(),
            nn.Dropout2d(0.2),  # 空间 dropout
            nn.MaxPool2d(2),

            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(),
            nn.Dropout2d(0.3),
            nn.MaxPool2d(2),

            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(),
            nn.Dropout2d(0.4),
            nn.MaxPool2d(2),
        )

        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(128 * 28 * 28, 512),
            nn.BatchNorm1d(512),
            nn.ReLU(),
            nn.Dropout(0.5),
            nn.Linear(512, num_classes),
        )

    def forward(self, x):
        x = self.features(x)
        x = self.classifier(x)
        return x

# 训练时加额外的正则化
def train_with_regularization(model, train_loader, num_epochs=10):
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
    criterion = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        model.train()
        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)

            outputs = model(images)
            loss = criterion(outputs, labels)

            # 额外的标签平滑
            label_smoothing = 0.1
            nll_loss = -torch.log_softmax(outputs, dim=1)
            smooth_loss = nll_loss.mean(dim=1)
            loss = (1 - label_smoothing) * loss + label_smoothing * smooth_loss.mean()

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

结构调整有效,但效果有限。更深、更宽的网络确实鲁棒性更好,但计算成本也上去了。而且模型越复杂,越难理解和调试,一旦出了问题排查难度也大。

监控和降级

后来想通了一件事:完全没有攻击的环境不存在。与其追求"绝对鲁棒",不如设计一套监控和降级机制,在检测到异常时主动降级或者拒绝服务。

# 简单的异常检测和降级
class RobustModelWrapper:
    def __init__(self, model, confidence_threshold=0.5):
        self.model = model
        self.confidence_threshold = confidence_threshold

    def predict_with_fallback(self, image):
        # 预处理
        processed = self.preprocess(image)

        # 模型预测
        output = self.model(processed)
        confidence, predicted = torch.max(torch.softmax(output, dim=1), 1)

        # 置信度检查
        if confidence < self.confidence_threshold:
            return {
                'status': 'low_confidence',
                'prediction': predicted.item(),
                'confidence': confidence.item(),
                'fallback': 'manual_review'
            }

        # 对抗样本检查(简化版)
        perturbed = generate_fgsm_samples(self.model, processed.unsqueeze(0), predicted.unsqueeze(0))
        output_perturbed = self.model(perturbed)
        confidence_perturbed, _ = torch.max(torch.softmax(output_perturbed, dim=1), 1)

        # 如果对抗扰动导致置信度大幅下降,可能是对抗样本
        if confidence_perturbed < self.confidence_threshold * 0.5:
            return {
                'status': 'adversarial_detected',
                'prediction': predicted.item(),
                'confidence': confidence.item(),
                'fallback': 'reject'
            }

        return {
            'status': 'ok',
            'prediction': predicted.item(),
            'confidence': confidence.item(),
            'fallback': None
        }

# 使用示例
wrapper = RobustModelWrapper(model, confidence_threshold=0.7)
result = wrapper.predict_with_fallback(test_image)

if result['fallback'] == 'manual_review':
    print("置信度不足,需要人工复核")
elif result['fallback'] == 'reject':
    print("检测到可能的对抗攻击,拒绝服务")
else:
    print(f"预测结果: {result['prediction']}, 置信度: {result['confidence']:.4f}")

这套机制在生产中比"硬抗"实用:遇到异常先降级,再逐步排查,不会一上来就崩。

现在的策略

经过一轮折腾,现在用的策略比较务实:

  1. 数据多样性优先:训练时尽可能覆盖各种噪声、压缩、变换场景,而不是只在"干净数据"上刷分。

  2. 适度对抗训练:隔几轮往 batch 里掺对抗样本,别全程对抗,训练成本和鲁棒性之间折中。

  3. 多层防御:模型前加预处理、模型内加正则化、模型后加监控检测,每一层都有作用。

  4. 降级预案:检测到异常时主动降级,而不是硬扛到崩溃。

  5. 持续监控:生产环境的错误率、置信度分布、输入特征分布,这些指标要持续跟踪。

还没完全解决的问题

有些问题到现在也没完全解决:

迁移攻击:在模型 A 上生成的对抗样本,对模型 B 依然有效。这说明对抗样本利用的是"数据本身的脆弱性",而不是特定模型的漏洞。

# 迁移攻击示例
model_A = load_model('model_A.h5')
model_B = load_model('model_B.h5')

# 在模型 A 上生成对抗样本
adv_sample = generate_adversarial_sample(model_A, test_image)

# 在模型 A 和模型 B 上测试
pred_A = model_A.predict(adv_sample)
pred_B = model_B.predict(adv_sample)

print(f"模型 A 在对抗样本上的准确率: {evaluate_model(model_A, adv_samples):.2f}%")
print(f"模型 B 在对抗样本上的准确率: {evaluate_model(model_B, adv_samples):.2f}%")
# 即使模型 A 和 B 结构完全不同,迁移攻击成功率依然很高

物理世界攻击:打印对抗样本再拍照喂模型,攻击照样生效——扰动在模型眼里是真实视觉信号,不是屏幕上的花屏 artifact。

评估标准缺失:准确率不等于鲁棒性,但业界又缺乏统一的标准去衡量"鲁棒性"。每个团队都说自己模型"够鲁棒",但没人能给出一个可比较的数字。

收个尾

做鲁棒性最大的体会:实验室的干净数据和线上脏数据,差得比想象大。测试集 99% 不代表生产就稳;对抗攻击也不是论文 demo 独有。

我现在不追求「绝对安全」,监控、检测、降级先搭好,模型上了线还得跟着攻击方式迭代。不敢说已经「够鲁棒」,至少比当初一碰就碎那版强,也知道下次翻车该从哪查。

参考

  • Goodfellow, I. J., et al. “Explaining and harnessing adversarial examples.” ICLR 2015.
  • Madry, A., et al. “Towards deep learning models resistant to adversarial attacks.” ICLR 2018.
  • Carlini, N., & Wagner, D. “Towards evaluating the robustness of neural networks.” IEEE S&P 2017.
  • Athalye, A., et al. “Synthesizing robust adversarial examples.” ICML 2018.

版权声明: 本文首发于 指尖魔法屋-AI鲁棒性:噪声与对抗防御笔记https://blog.thinkmoon.cn/post/181-ai-robustness-noise-adversarial-defense-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!