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%。这就是典型的"鲁棒性换性能"。
在训练中还踩过几个坑:
ε 的选择很敏感:ε 太小对抗效果不明显,ε 太大破坏图像语义。最后试下来 ε=0.007 比较平衡。
对抗样本生成成本高:每个 batch 都要算梯度、生成对抗样本,训练时间几乎翻倍。后来改成"隔轮对抗"——隔一个 batch 用一次对抗样本,时间降下来但效果也掉了点。
过拟合对抗样本:模型会过拟合特定攻击生成的对抗样本,对新的攻击方式依然脆弱。后来改成"多策略对抗训练",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}")
这套机制在生产中比"硬抗"实用:遇到异常先降级,再逐步排查,不会一上来就崩。
现在的策略
经过一轮折腾,现在用的策略比较务实:
数据多样性优先:训练时尽可能覆盖各种噪声、压缩、变换场景,而不是只在"干净数据"上刷分。
适度对抗训练:隔几轮往 batch 里掺对抗样本,别全程对抗,训练成本和鲁棒性之间折中。
多层防御:模型前加预处理、模型内加正则化、模型后加监控检测,每一层都有作用。
降级预案:检测到异常时主动降级,而不是硬扛到崩溃。
持续监控:生产环境的错误率、置信度分布、输入特征分布,这些指标要持续跟踪。
还没完全解决的问题
有些问题到现在也没完全解决:
迁移攻击:在模型 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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。